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
80 changes: 80 additions & 0 deletions blueprint/dockview-named-layouts/plan-dockview-named-layouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Plan: Named dockview layouts for the default workspace template

## Overview

- Replace the single implicit dockview layout (`workspace_layout/layout.json` in the system_interface of default-workspace-template) with multiple named layouts stored as separate JSON files, plus a small amount of workspace-level layout state (display names, last-active tracking).
- The template ships two default layout names, `desktop` and `mobile`. Defaults are lazy: the names always exist, and a layout with no saved content triggers the existing auto-open-welcome-chat behavior on the client, whose autosave then materializes the file. An existing `layout.json` is migrated to become `desktop`.
- Each browser client keeps its own "active layout" (persisted in localStorage, first-time default chosen by user agent: mobile vs desktop). All existing autosave/restore behavior is unchanged except that it targets the active layout's file.
- The "+" menu gains a bottom section with "Save layout...", "Load layout...", and "Delete layout..." dialogs, plus live cross-client sync (re-apply on save by another client, auto-switch on delete).
- Agents learn which client/layout a request came from via a new workspace-level append-only `events.jsonl` (message + layout-switch events) and a new `layout.py context` subcommand; mutating `layout.py` ops become layout-targeted (`--layout`, required) and only work when a connected client has that layout active — no server-side JSON mutation of layouts.

## Expected behavior

### Layout storage and defaults

- Layouts are named, server-persisted dockview states (same `SavedLayout` shape as today: dockview JSON + panelParams), one JSON file per layout under the primary agent's `workspace_layout/` dir. Filenames are slugs; display names are free-form and preserved. A save whose slug collides with a *different* existing layout's slug is rejected with a clear error.
- `desktop` and `mobile` always exist as layout names, even before any content is saved. Loading a layout with no saved content behaves exactly like today's "no saved layout" path: the client auto-opens the initial welcome chat tab and its autosave materializes the file.
- On first request after upgrade, a legacy `workspace_layout/layout.json` is migrated to be the `desktop` layout's content; `mobile` starts fresh. No other migration.

### Client behavior

- On first connect ever (no localStorage selection), the client picks `mobile` if the user agent is mobile (`navigator.userAgentData?.mobile`, falling back to a UA-string regex), else `desktop`. If that layout name no longer exists, it falls back to the first existing layout.
- The selection persists per browser in localStorage; every subsequent connect restores that layout (falling back as above if it was deleted).
- Every dockview change auto-persists (existing 1.5 s debounce) to the client's active layout. Switching layouts (load, save-as, delete-fallback) flushes any pending autosave of the old layout first, so no changes are lost.
- The "+" menu (both the group-header button and the empty-state overlay) gains a divider and three items at the bottom:
- "Save layout...": dialog with the list of existing layouts and a free-text name field, prefilled with the active layout's name (one-click save-over-current). Uniform rule: after any save, the client is on the layout it saved to — saving under a new name creates it and switches, and overwriting a different existing layout switches too.
- "Load layout...": dialog listing all layouts; selecting one applies it and makes it the autosave target.
- "Delete layout...": dialog listing all layouts; the only guard is that the last remaining layout cannot be deleted.
- All three dialogs mark the client's active layout with "(current)".
- Live cross-client sync: when a client saves a layout, other connected clients with that same layout active re-apply it. When a layout is deleted, clients with it active automatically switch to the first remaining layout and show a brief notice.
- Echo suppression for live sync (both guards): the save broadcast carries the saving client's id and receivers suppress the autosave that their re-apply would trigger, and clients additionally skip saving/broadcasting when the serialized layout is unchanged from what the server last sent them (content-hash guard).

### Event recording (agent context)

- A workspace-level append-only events file lives next to the layouts (e.g. `workspace_layout/events/client_activity/events.jsonl`), following the repo's standard event envelope (timestamp, type, event_id, source). No rotation in v1.
- Every message sent through the UI appends a message event: message text (truncated at write time, e.g. first ~500 chars — full transcripts live elsewhere), time, target agent, client id (uuid minted per browser in localStorage), device kind from the UA (mobile/desktop), and the client's active layout at send time.
- Every layout switch appends a switch event (client id, device kind, old/new layout), regardless of initiator — user action, agent-driven `layout.py load`, or delete-fallback.
- Messages sent outside the UI (e.g. directly in tmux) have no metadata; the skill instructs the agent to fall back to the last active layout, mutate all layouts, or ask the user.

### Agent-facing layout ops (`scripts/layout.py`)

- Mutating ops (`open`, `split`, `close`, `move`, `rename`, `focus`, `maximize`, `restore`, `replace-url`) require an explicit `--layout <name>`; omitting it is an error, and the skill instructs the agent to always pass it. The op broadcasts only to connected clients whose active layout matches; if none, it fails with a clear error the agent can relay (no server-side JSON mutation).
- If multiple clients share the target layout, all of them apply the op (they converge via autosave + live sync).
- Read-only `inspect` / `list` / `where` accept `--layout` and read that named layout's persisted file directly (works with no client connected). Default resolution when omitted: the last active layout.
- New `layout.py load <name>`: switches the requesting client (resolved from message metadata; falls back to all clients when the requester is unknown) onto that layout, so the agent can then mutate it. An optional `--client <id>` targets a specific client explicitly (the agent can pick one after consulting `context`).
- New `layout.py context`: prints, per known client — client id, device kind from the UA (mobile/desktop), current layout, last-seen time, and the last ~5 messages (long messages truncated) — so the agent can figure out which client/layout a request refers to.
- The `manage-layout` skill is updated to document layout targeting, `context`, `load`, the `--layout` requirement, and the no-metadata fallback guidance.

## Changes

All changes land in the `default-workspace-template` repo (worked on via an `.external_worktrees/` checkout per monorepo convention), primarily `apps/system_interface`; a changelog entry is added there. No mngr monorepo code changes are expected beyond this blueprint (and vendor sync happens through the normal release flow).

- **Server (`imbue/system_interface/server.py` + a new layouts module)**
- Replace the single-file GET/POST `/api/layout` with named-layout endpoints: list layouts (slug, display name, current-content flag), get/save a named layout, delete a named layout (guarding the last one), all rooted in the existing `workspace_layout/` dir.
- Slug validation/derivation from display names; reject slug conflicts on save.
- Legacy `layout.json` migration to `desktop` on first access.
- Track last-active layout and connected clients' active layouts via an in-memory registry keyed by WS connection: each client sends `{client_id, active_layout}` on WS open and on every switch, and its entry dies with the connection ("connected" = WS open).
- Append message events (in the message-send endpoint) and layout-switch events to the workspace-level `events.jsonl` per the standard envelope.
- WS broadcasts for live sync: "layout saved" (clients on it re-apply) and "layout deleted" (clients on it switch + notice), plus the agent-driven "load layout" op routed to the requesting client.

- **Layout broadcast path (`imbue/system_interface/layout_ops.py`)**
- Mutating ops carry the target layout and are delivered only to matching clients; error when no client has the layout active.
- `inspect`/`list` resolve a named layout file (defaulting to last active).
- New `context` op assembled from `events.jsonl` (per-client recent messages, current layout, device kind, last-seen).
- New `load` op (switch requesting client, explicit `--client <id>` override, fall back to all clients).

- **Frontend (`frontend/src/views/DockviewWorkspace.ts` + new dialog view(s), `models/AgentManager.ts`, `views/MessageInput.ts` or message-send path)**
- Client id (uuid) + active layout name in localStorage; UA-based first-time default with fall-back to first existing layout.
- Load/save against named-layout endpoints; autosave targets the active layout; flush pending autosave before any switch.
- "+" menu bottom section with the three dialogs (Save prefilled with current name; "(current)" markers; last-layout delete guard surfaced from the server).
- Handle new WS messages: re-apply on same-layout save by another client, auto-switch + notice on delete, agent-driven load, and layout-targeted layout ops (apply only when the target layout is active).
- Send client id + active layout with every chat message; report active layout over WS on connect and on switch.

- **Agent tooling (`scripts/layout.py`, `.agents/skills/manage-layout/SKILL.md`)**
- Required `--layout` on mutating subcommands; optional on read-only ones; new `load` and `context` subcommands.
- Skill doc updates: layout targeting workflow (context → load if needed → mutate), no-metadata fallback guidance (last active layout / all layouts / ask).

- **Tests**
- Server: named-layout CRUD, slug conflicts, migration, last-layout delete guard, event appends, last-active tracking.
- Layout ops: `--layout` routing/error paths, `context` assembly, `load` targeting.
- Frontend unit tests where the existing suite has coverage (e.g. UA default choice, slug/display handling in dialogs); e2e layout pipeline test updated for named layouts.
3 changes: 3 additions & 0 deletions dev/changelog/mngr-add-dockview-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add the blueprint for named dockview layouts in the default workspace template (`blueprint/dockview-named-layouts/`).

The implementation itself lands in the `default-workspace-template` repo (same branch name there): named `desktop`/`mobile` layouts with per-client selection, "+"-menu save/load/delete dialogs, live cross-client sync, a client-activity event log, and layout-targeted `layout.py` ops with new `context` and `load` subcommands.
5 changes: 5 additions & 0 deletions libs/mngr/changelog/mngr-add-dockview-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fix the Docker provider's create-time host-name collision check matching containers from other mngr environments.

`_find_container_by_name` filtered only by the `host-name` and `provider` labels, which carry no environment discriminator -- two environments differing only in `MNGR_PREFIX` (e.g. two minds envs) label their containers identically, so creating a host whose name was in use in a *different* environment failed with a misleading "container already exists" error naming a container that does not exist. The lookup now also requires the container's actual name to match `<prefix><host-name>` (the same uniqueness scope Docker itself enforces and the same prefix filter discovery applies), so collisions are detected only within the current environment. The same lookup backs host resolution by name, so `mngr` commands addressed by host name also no longer resolve to (and act on) another environment's same-named container.

Test-only: give `test_extras_claude_plugin_subcommand` timeout headroom (it shells out to the `claude` Node CLI, whose startup can exceed the global 10s pytest-timeout on a contended CI sandbox).
8 changes: 8 additions & 0 deletions libs/mngr/imbue/mngr/cli/extras_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,14 @@ def test_extras_completion_subcommand(cli_runner: CliRunner) -> None:
assert result.exit_code == 0


# Shells out to the `claude` CLI (`claude plugin list --json`) to read plugin
# status. That Node process's startup dominates the runtime and can exceed the
# global 10s pytest-timeout on a contended CI sandbox, even though the test runs
# in well under a second locally (observed: "Failed: Timeout (>10.0s)" in
# offload). Exercising the real subcommand end-to-end is the point of the test,
# so the shell-out cannot be removed; give it headroom instead -- the same
# treatment `test_extras_no_args_shows_status` already carries for this cause.
@pytest.mark.timeout(30)
def test_extras_claude_plugin_subcommand(cli_runner: CliRunner) -> None:
"""The 'extras claude-plugin' subcommand should work."""
result = cli_runner.invoke(extras, ["claude-plugin"])
Expand Down
27 changes: 22 additions & 5 deletions libs/mngr/imbue/mngr/providers/docker/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
from imbue.mngr.providers.docker.volume import STATE_CONTAINER_TYPE_VALUE
from imbue.mngr.providers.docker.volume import STATE_VOLUME_MOUNT_PATH
from imbue.mngr.providers.docker.volume import ensure_state_container
from imbue.mngr.providers.docker.volume import host_container_name
from imbue.mngr.providers.docker.volume import state_container_name
from imbue.mngr.providers.docker.volume import state_volume_name
from imbue.mngr.providers.ssh_host_setup import REQUIRED_HOST_PACKAGES
Expand Down Expand Up @@ -1060,7 +1061,19 @@ def _find_container_by_host_id(self, host_id: HostId) -> docker.models.container
return None

def _find_container_by_name(self, name: HostName) -> docker.models.containers.Container | None:
"""Find a Docker container by host_name label."""
"""Find this environment's Docker container for host ``name``.

The host-name and provider labels carry no environment discriminator:
two mngr environments with different ``MNGR_PREFIX`` values (e.g. two
minds envs) both label their containers ``provider=docker`` +
``host_name=<name>``, so a label-only lookup can match another
environment's container. Require the container's actual name to be the
one ``host_container_name`` assigns, which scopes the match to this
environment (the same prefix filter ``_list_containers`` applies to
discovery). Containers are never renamed after creation -- ``mngr
rename`` updates only the host record -- so name and label stay in
lockstep.
"""
try:
containers = self._docker_client.containers.list(
all=True,
Expand All @@ -1069,7 +1082,11 @@ def _find_container_by_name(self, name: HostName) -> docker.models.containers.Co
except docker.errors.DockerException as e:
raise MngrError(f"Cannot connect to Docker daemon: {e}") from e

return containers[0] if containers else None
expected_container_name = host_container_name(self.mngr_ctx.config.prefix, name)
for container in containers:
if container.name == expected_container_name:
return container
return None

def _list_containers(self) -> list[docker.models.containers.Container]:
"""List all Docker containers managed by this provider instance.
Expand Down Expand Up @@ -1309,7 +1326,7 @@ def create_host(

# Fail fast if a container with this name already exists, before the
# expensive image build step.
container_name = f"{self.mngr_ctx.config.prefix}{name}"
container_name = host_container_name(self.mngr_ctx.config.prefix, name)
existing = self._find_container_by_name(name)
if existing is not None:
raise MngrError(
Expand Down Expand Up @@ -1628,7 +1645,7 @@ def _start_from_snapshot(
logger.info("Restoring Docker container from snapshot", host_id=str(host_id), snapshot_id=str(snapshot_id))

labels = build_container_labels(host_id, host_name, str(self.name), user_tags)
container_name = f"{self.mngr_ctx.config.prefix}{host_name}"
container_name = host_container_name(self.mngr_ctx.config.prefix, host_name)

effective_start_args = config.start_args

Expand Down Expand Up @@ -1904,7 +1921,7 @@ def get_host(
if host_record is not None:
host_obj = self._create_host_from_host_record(host_record)
else:
# Try container label lookup first (fast path)
# Try this environment's container for that host name first (fast path)
container = self._find_container_by_name(host)
if container is not None and self._is_container_running(container):
host_obj = self._create_host_from_container(container)
Expand Down
Loading
Loading