Skip to main content

Layout shell: one frontend, two layouts

The browser app and the desktop app render the same React frontend. There is no per-platform fork of the UI. The differences between the two are confined to (a) the app entry that boots the shell and (b) the platform capabilities exposed to modules.

Implementation status

Implemented: module registry, command palette (Ctrl+K), keybinding service (mod+ prefix), capability service, and both entries (apps/web on port 5173 proxying /api and /ws to the backend on port 8000; apps/desktop Tauri shell loading the same dev server). The shared /ws socket is consumed via a single multiplexed client (packages/core/src/ws.ts, channel subscriptions + sendChannel) — its users are the observability telemetry channel (see ../modules/observability.md) and the agent channel that drives the agent orchestrator (see ../modules/agent-chat.md). The backend /ws handler is a bidirectional router: telemetry pushes outbound while one receive loop dispatches inbound channel messages.

The shell has two top-level views (switched by the shell's own shell.home / shell.workspace commands):

  • home — the default on open: a minimal centered surface (3D dashboard-friend avatar, greeting, ask bar streaming from the local model) that hosts agent onboarding until a local model is configured (see docs/modules/agent-chat.md). Modeled on chat-first launchers, deliberately free of workspace chrome. The avatar (packages/core/src/Avatar3D.tsx — moved to core so feature modules like the agent chat pane can use it too) is a rigged glTF character (/my-avatar.glb) loaded with three's GLTFLoader, with pointer-tracking. It expresses the agent's emotional mood as a looping animation: the moods prop maps mood names to animation clips (DEFAULT_AVATAR_MOODShappy → /dancing.glb, flair → /flair.glb, error → /falling-over.glb, all in apps/web/public/) and the mood prop selects the active one, cross-fading on change. Adding a mood is one .glb plus one line in the map. Orbiting the avatar is Dashy, the dashboard mascot — a cute glowing orb with eyes that face the viewer, whose glow doubles as the agent status light. three is dynamically imported so the workspace view never loads it.
  • workspace — a dockable window manager: module panes (panels and widgets) open as windows that can be tabbed, split, resized, and floated. Several workflow layouts (Blender-style workspaces) exist — predefined presets like Dashboard and Scripting plus any custom ones — switchable from the shell rail; each layout's arrangement persists. Built on dockview, wrapped so the registry stays the public API. See windowing.md for the model, pane types vs instances, layout presets, and persistence.

A persistent icon rail (logo/home on top, one icon per workflow layout + a to add a custom one, command palette at the bottom) is the only chrome shared by both views — it is the workspace switcher (Blender-style), not a pane launcher. Clicking a layout switches to the workspace view and activates it; individual panes are opened from the command palette instead. The Workspace stays mounted across view switches so its layout survives a trip home.

Branding: source art lives in assets/ (logo.svg, banner.svg) — pnpm build:icons regenerates the web favicon (apps/web/public/favicon.ico, 16/32/48), the SVG favicon copy, and the Tauri app icons (full size set) from assets/logo.svg. The shell header shows the logo; assets/banner.svg is the README banner.

The workspace

The shell owns a dockable workspace: panels arranged in split panes and tab groups, persisted per layout profile. Modules never render themselves into the DOM directly — they register panels with the module registry (packages/core), and the workspace decides where panels live (default placement comes from the panel declaration; the user can rearrange freely).

Everything user-facing routes through two shell services:

  • Command palette — every module capability is a registered command (module.verb ids, e.g. terminal.new, chat.focusInput). Panels' buttons invoke commands; the palette and keybindings invoke the same commands. This is what keeps both layouts behaviorally identical.
  • Keybinding service — maps keys to command ids with focus scopes. Modules declare defaults; the shell resolves them against the focused pane (see below) and (on desktop) registers any global shortcuts.

Keybinding scopes

A KeybindingDecl ({ key, command }) is global by default — active everywhere. Two optional fields make bindings focus-aware:

  • scope — a pane view id (e.g. terminal.instance). The binding is only active while a pane of that view is focused, and it shadows a plain global binding for the same key. So a focused terminal can bind mod+k to terminal.clear while mod+k opens the command palette everywhere else.
  • override (on a global binding) — when true, the global wins even if the focused pane has a scoped binding for the same key. The escape hatch for shortcuts that must never be shadowed.

The shell tracks the active scope — the focused pane's view id — in packages/core/src/keybindings.ts: the workspace pane host calls setActiveScope on focus/pointer-down and clearActiveScope on unmount. The pure resolveKeybinding(event, scope, bindings) picks the command with precedence override-global → focused-scope → plain-global, so most globals are overridable by the focused pane but an override global always wins — the "may or may not override" rule. (CodeMirror/xterm keep their own internal keymaps; this service is for registry command bindings.)

Platform capability service

Modules must never check window.__TAURI__ or user-agent sniff. packages/core exposes a capability service; feature code branches on capabilities and degrades gracefully:

CapabilityBrowserDesktop (Tauri)
fs.nativeDialogsno (backend workspace roots only)yes (native open/save dialogs)
shell.revealInOSnoyes
notifications.systemWeb Notifications (tab-scoped)OS notifications
window.multino (single tab, pop-out = new tab)yes (panel pop-out to OS window)
shortcuts.globalnoyes (summon window, quick chat)
traynoyes

Backend connection

Both layouts talk to the same FastAPI backend over HTTP + WebSocket, through one origin switch (packages/core/src/origin.ts, set once at boot via initBackendOrigin):

  • Browser: origin stays null — paths are relative (/api, /ws) and the Vite dev server proxies them to localhost:8000. Start the backend yourself (scripts/dev-backend.ps1 or uv run uvicorn backend.app:app --port 8000). Anything that executes on the backend (terminals, file access, agents) runs where the backend runs, not where the browser runs. Module docs call this out where it matters.
  • Desktop: the Tauri shell spawns and supervises the backend itself (apps/desktop/src-tauri/src/backend.rs). On launch it locates the repo checkout, reuses an already-running backend on :8000 if there is one (dev-backend.ps1 coexistence), otherwise picks a port (8000 preferred, ephemeral fallback) and spawns uvicorn bound to 127.0.0.1 — via the repo's .venv python when present, else uv run — with MinGW dirs stripped from the child PATH (the no OPENSSL_Applink crash). It restarts the process with backoff if it dies (3 fast failures → failed with the stderr tail) and kills it on app exit. The frontend polls the backend_status Tauri command at boot and uses absolute http/ws URLs from then on — no Vite proxy in the loop, so dev and packaged builds exercise the same path. If the backend never comes up, boot proceeds pluginless and the home view shows the backend-down hint.

A packaged desktop build has no repo checkout: the supervisor reports unavailable, which is the plug point for a future bundled/downloaded runtime (python-build-standalone). A per-session auth token between shell and backend is a planned follow-up — today anything on localhost can reach the API.

The shell owns a single multiplexed WebSocket connection with reconnect/backoff; modules subscribe to channels on it rather than opening their own sockets.

Toast notifications

The layout shell contributes a global Toast Notification System for displaying non-blocking, transient visual alerts (success, info, warning, error) that slide in from the top-right corner. It is designed to be usable by any module or plugin to announce background events (such as peer connections, file exports, or network status changes).

  • Store (toastsStore) — lives in packages/core/src/toasts.ts (exported as toastsStore and type Toast from @horrible/core). Operates as a simple pub/sub store with React useSyncExternalStore support:
    • toastsStore.add(type, title, message, duration?) — triggers a new notification. Default duration is 4 seconds (4000ms); set to 0 or negative to keep it persistent until closed.
    • toastsStore.remove(id) — clears a notification by its unique ID.
  • Component (Toasts) — lives in packages/ui/src/Toasts.tsx (rendered in the global AppShell layout overlay). Uses glassmorphism styling (backdrop-filter: blur(12px)) and cubic-bezier transition slide animations defined in packages/ui/src/styles.css.

Usage Example

import { toastsStore } from '@horrible/core';

// Success notification
toastsStore.add('success', 'File Exported', 'Workspace layout successfully saved to disk.');

// Error notification with persistent display
toastsStore.add('error', 'Sync Failed', 'Failed to connect to the peer fabric.', 0);

Toasts inform; dialogs ask. The shell contributes a promise-based modal system that replaces the browser's window.prompt / window.confirm (which are unstyled, untestable, and block the whole tab). No module calls the native dialogs anymore — they all route through here, so input and confirmation look and behave the same in both layouts.

  • Store (dialogsStore / dialogs)packages/core/src/dialogs.ts, exported from @horrible/core. Requests queue (one shown at a time) and each returns a promise:
    • dialogs.prompt({ title, message?, defaultValue?, placeholder?, confirmLabel?, cancelLabel? })Promise<string | null> (null on cancel).
    • dialogs.confirm({ title, message?, confirmLabel?, cancelLabel?, danger? })Promise<boolean>. danger: true renders the confirm button red for destructive actions (delete).
  • Component (Dialogs)packages/ui/src/Dialogs.tsx, mounted in AppShell beside <Toasts />. Renders the active dialog centered over a dimmed backdrop; Enter submits, Escape and backdrop-click cancel, the field auto-focuses. Styles (.dialog-*) live in packages/ui/src/styles.css.

Outside React (commands, CodeMirror keymaps) call await dialogs.prompt(...) directly; where a handler must return synchronously (e.g. an editor keymap), fire the promise and act in .then(...). Pair the result with a toast for feedback — e.g. confirm a delete, then toastsStore.add('info', 'Deleted', …).

Usage Example

import { dialogs, toastsStore } from '@horrible/core';

const name = await dialogs.prompt({ title: 'New workspace', confirmLabel: 'Create' });
if (name?.trim()) {
await createWorkspace(name.trim());
toastsStore.add('success', 'Workspace created', `${name.trim()}” is ready.`);
}

const ok = await dialogs.confirm({
title: 'Delete file',
message: "This can't be undone.",
confirmLabel: 'Delete',
danger: true,
});
if (ok) await remove();

What belongs in each entry

  • apps/web: boot the shell, resolve the backend origin (Tauri: ask the shell's backend_status command; browser: relative + proxy), register the browser capability set. Nothing feature-specific.
  • Boot is async: after registering built-in modules, the entry awaits loadPlugins() (installed marketplace plugins, see plugin-sdk.md) before the first render, so restored layouts find plugin panels/widgets already registered. If the backend is down, boot proceeds without plugins.
  • apps/desktop: same boot, plus Tauri-only wiring — window chrome, tray, global shortcuts, multi-window pop-out, native menu that dispatches to the same command registry. Rust code in src-tauri/ stays a thin shell; app logic belongs in the backend or packages/.