Frontend architecture
September 17, 2026 · View on GitHub
Developer reference for the Vue 3 web dashboard. Covers the component tree, store design, WebSocket protocols, and how to add new panels.
Dev loop
Source lives in src/kohakuterrarium-frontend/. Built output lands in
src/kohakuterrarium/web_dist/ (configured in vite.config.js:48),
where the FastAPI app in api/app.py and serving/web.py pick it up
as static files.
# Dev server (hot reload, points at the Python API via proxy)
npm run dev --prefix src/kohakuterrarium-frontend
# Production build (writes into src/kohakuterrarium/web_dist)
npm run build --prefix src/kohakuterrarium-frontend
# Lint / format
npm run lint --prefix src/kohakuterrarium-frontend
npm run format --prefix src/kohakuterrarium-frontend
# Unit tests (vitest + jsdom)
npm run test --prefix src/kohakuterrarium-frontend
When distributing KT, run npm run build so web_dist/ is populated
before pip install -e . or packaging. The Python side ships the
built bundle as part of the installed package.
Stack
- Vue 3.5+ with
<script setup>composition API - Pinia 3 for state management (options API stores for chat, composition API for layout/canvas/palette)
- Vite (rolldown-vite) with UnoCSS, unplugin-auto-import, unplugin-vue-components, unplugin-vue-router
- Element Plus 2.11 for dialogs, dropdowns, selects, tooltips
- Monaco Editor for code editing
- Vditor for rich markdown editing
- xterm.js for the terminal panel
- highlight.js for canvas code viewer
- splitpanes (legacy, used in old SplitPane.vue component only)
Directory structure
src/kohakuterrarium-frontend/src/
├── App.vue # Root: NavRail + router-view + global composables
├── main.js # Pinia + router + panel registration (sync!)
├── style.css # Theme variables, font stacks
├── components/
│ ├── chat/ # ChatPanel, ChatMessage, ToolCallBlock
│ ├── chrome/ # AppHeader, StatusBar, ModelSwitcher,
│ │ CommandPalette, ToastCenter
│ ├── common/ # StatusDot, SplitPane, GemBadge, MarkdownRenderer
│ ├── editor/ # EditorMain, MonacoEditor, VditorEditor,
│ │ FileTree, FileTreeNode, EditorStatus
│ ├── layout/ # WorkspaceShell, LayoutNode, EditModeBanner,
│ │ PanelHeader, PanelPicker, SavePresetModal,
│ │ NavRail, NavItem, Zone*.vue (legacy)
│ ├── panels/ # ActivityPanel, StatePanel, FilesPanel,
│ │ CreaturesPanel, CanvasPanel, SettingsPanel,
│ │ DebugPanel, TerminalPanel
│ │ ├── canvas/ # CodeViewer, MarkdownViewer, HtmlViewer
│ │ ├── debug/ # LogsTab, TraceTab, PromptTab, EventsTab
│ │ └── settings/ # ModelTab, PluginsTab, ExtensionsTab, etc.
│ ├── registry/ # ConfigCard
│ └── status/ # StatusDashboard (large tabbed status panel)
├── composables/
│ ├── useKeyboardShortcuts.js # Ctrl+1..6, Ctrl+Shift+L, Ctrl+K
│ ├── useBuiltinCommands.js # Palette command registry
│ ├── useAutoTriggers.js # Canvas notification, error→debug
│ ├── useArtifactDetector.js # Scans chat for code blocks → canvas store
│ ├── useLogStream.js # /ws/logs WebSocket composable
│ └── useFileWatcher.js # /ws/files WebSocket composable (unused on Windows)
├── stores/
│ ├── chat.js # WebSocket chat, messages, runningJobs, tokenUsage
│ ├── layout.js # Presets, panels, edit mode, split tree mutations
│ ├── layoutPanels.js # Panel + preset registration (called from main.js)
│ ├── canvas.js # Artifact detection + storage
│ ├── files.js # Touched files derived from chat events
│ ├── scratchpad.js # Scratchpad REST client
│ ├── palette.js # Command palette registry + fuzzy search
│ ├── notifications.js # Toast + history
│ ├── instances.js # Running instance list
│ ├── editor.js # Open files, active file, tree
│ ├── theme.js # Dark/light toggle
│ └── ...
├── pages/
│ ├── instances/[id].vue # Main instance view (WorkspaceShell)
│ ├── editor/[id].vue # Editor-focused view (WorkspaceShell)
│ ├── detached/[key].vue # Pop-out single panel
│ ├── panel-debug.vue # Debug page: each panel as a tab
│ ├── index.vue, new.vue, sessions.vue, registry.vue, settings.vue
│ └── ...
└── utils/
├── api.js # Axios HTTP client (all REST endpoints)
└── layoutEvents.js # CustomEvent bus for cross-component actions
Layout system
Binary split tree
The layout is a recursive binary tree where each node is:
// Split: two children with a draggable divider
{ type: "split", direction: "horizontal"|"vertical", ratio: 0-100, children: [Node, Node] }
// Leaf: renders one panel
{ type: "leaf", panelId: "chat" }
LayoutNode.vue is the recursive renderer. For splits, it renders two
children in a flex container with a pointer-captured drag handle. For
leaves, it resolves the panel component from the layout store and
renders it via <component :is>.
Panel registration
Panels are registered in stores/layoutPanels.js at app startup
(synchronous, before app.mount()):
layout.registerPanel({
id: "chat",
label: "Chat",
description: "The conversation with the focused creature.",
component: ChatPanel,
});
The component is wrapped in markRaw() internally so Vue reactivity
doesn't wrap it. description is the English fallback shown in the
panel picker and palette; utils/i18n.js translates both label and
description by panel id. Pass hidden: true for an alias a legacy
preset still references but users should never be offered; such panels
stay resolvable but drop out of layout.visiblePanelList.
Presets
Presets are tree definitions with an id, label, and optional shortcut:
const CHAT_FOCUS = {
id: "chat-focus",
label: "Chat Focus",
shortcut: "Ctrl+1",
tree: hsplit(70, leaf("chat"), vsplit(40, leaf("status-tab"), leaf("state"))),
};
Helper functions hsplit(ratio, left, right), vsplit(ratio, top, bottom),
and leaf(panelId) create the tree nodes concisely. DEFAULT_PRESET_ID
names the preset every fresh attach tab lands on; the instance's shape
never picks a different one.
Panel props
Route pages (e.g., pages/instances/[id].vue) provide runtime props to
panels via Vue's provide("panelProps", computed(() => ({...}))). The
LayoutNode injects this and passes the appropriate slice to each leaf
component based on panelId.
Edit mode
layout.enterEditMode() deep-clones the active preset. All tree
mutations (replace, split, close) operate on the clone.
layout.exitEditMode() restores the original. layout.saveEditMode()
persists the clone (user presets only).
WebSocket protocols
Chat (/ws/creatures/{agent_id} or /ws/terrariums/{id})
Existing; managed by stores/chat.js. Streams text chunks, tool
start/done, token usage, session info, compaction events.
Logs (/ws/logs)
Server process log tail. Messages: {type: "meta"|"line"|"error", ...}.
Lines parsed into {ts, level, module, text}.
Terminal (/ws/terminal/{agent_id})
PTY shell in the agent's working directory. Messages:
- Client → Server:
{type: "input", data: "..."},{type: "resize", rows, cols} - Server → Client:
{type: "output", data: "..."},{type: "error", data: "..."}
Files (/ws/files/{agent_id})
File system watcher (watchfiles). Messages:
{type: "ready"|"change"|"error", ...}. Changes include path + action
(added/modified/deleted). Currently unreliable on Windows.
Adding a new panel
- Create
components/panels/MyPanel.vue - Register in
stores/layoutPanels.js:import MyPanel from "@/components/panels/MyPanel.vue"; layout.registerPanel({ id: "my-panel", label: "My Panel", component: MyPanel }); - Add to a preset tree:
tree: hsplit(50, leaf("chat"), leaf("my-panel")) - If the panel needs runtime props (like
instance), add an entry to the route page'spanelPropscomputed.
Canvas publications
The canvas detector observes tool-result replacements as well as transcript
growth, so a background completion can update an older message while the
controller is idle. Tool jobs retain their canvas_preview metadata in live
events and persisted history. For earlier records missing that metadata,
successful canvas_image results supply the image and path directly.
Canvas dismissal records are browser-local and scoped by attach target. Tool job IDs identify publications across live/replayed messages and relative versus resolved path spellings. Message-derived images and text blocks still use message IDs, so their dismissal across live-to-history rekeying is best effort.
Theme
stores/theme.js manages dark/light mode. Components use
useThemeStore().dark reactively. CSS uses html.dark class for dark
mode overrides. UnoCSS dark: prefix works throughout.
Studio applies kt-conversation-host to its ChatTranscriptSection instance
to supply the shared conversation palette. The shared transcript component
inherits its host's tokens; it does not establish another palette scope.
VS Code supplies its own scope and maps tokens to editor theme variables.
For global dark selectors in scoped Vue styles, keep the complete selector
inside :global(...), for example
:global(html.dark .kt-chat-composer__shell). Combining
:global(.dark) :deep(...) can compile to a bare .dark rule and lose the
target component selector.
Vditor and xterm.js have their own theme systems; both watch
themeStore.dark and call their respective theme-switch APIs.