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 (seedocs/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'sGLTFLoader, with pointer-tracking. It expresses the agent's emotional mood as a looping animation: themoodsprop maps mood names to animation clips (DEFAULT_AVATAR_MOODS—happy → /dancing.glb,flair → /flair.glb,error → /falling-over.glb, all inapps/web/public/) and themoodprop selects the active one, cross-fading on change. Adding a mood is one.glbplus 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.verbids, 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 bindmod+ktoterminal.clearwhilemod+kopens 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:
| Capability | Browser | Desktop (Tauri) |
|---|---|---|
fs.nativeDialogs | no (backend workspace roots only) | yes (native open/save dialogs) |
shell.revealInOS | no | yes |
notifications.system | Web Notifications (tab-scoped) | OS notifications |
window.multi | no (single tab, pop-out = new tab) | yes (panel pop-out to OS window) |
shortcuts.global | no | yes (summon window, quick chat) |
tray | no | yes |
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 tolocalhost:8000. Start the backend yourself (scripts/dev-backend.ps1oruv 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:8000if there is one (dev-backend.ps1 coexistence), otherwise picks a port (8000 preferred, ephemeral fallback) and spawns uvicorn bound to127.0.0.1— via the repo's.venvpython when present, elseuv run— with MinGW dirs stripped from the child PATH (theno OPENSSL_Applinkcrash). It restarts the process with backoff if it dies (3 fast failures →failedwith the stderr tail) and kills it on app exit. The frontend polls thebackend_statusTauri 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 inpackages/core/src/toasts.ts(exported astoastsStoreand typeToastfrom@horrible/core). Operates as a simple pub/sub store with ReactuseSyncExternalStoresupport:toastsStore.add(type, title, message, duration?)— triggers a new notification. Default duration is 4 seconds (4000ms); set to0or negative to keep it persistent until closed.toastsStore.remove(id)— clears a notification by its unique ID.
- Component (
Toasts) — lives inpackages/ui/src/Toasts.tsx(rendered in the globalAppShelllayout overlay). Uses glassmorphism styling (backdrop-filter: blur(12px)) and cubic-bezier transition slide animations defined inpackages/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);
Modal dialogs (prompt / confirm)
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: truerenders the confirm button red for destructive actions (delete).
- Component (
Dialogs) —packages/ui/src/Dialogs.tsx, mounted inAppShellbeside<Toasts />. Renders the active dialog centered over a dimmed backdrop; Enter submits, Escape and backdrop-click cancel, the field auto-focuses. Styles (.dialog-*) live inpackages/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'sbackend_statuscommand; 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 insrc-tauri/stays a thin shell; app logic belongs in the backend orpackages/.