Vancetope Web-UI — Specification
Status: v1 Initial Draft. This spec is binding for the development of the Web-UI under
repos/vance/client/(symlinkclient_web/).
1. Goals and Scope
Vancetope will receive a browser-based UI as an addition to the existing CLI clients (vance-cli v1, vance-foot v2). The Web-UI is a separate client that does not aim for feature parity with the CLIs. It uses the existing backend (vance-brain) via REST and WebSocket.
What the Web-UI is:
- A Workbench plus a set of standalone pages. The Workbench (Landing, Cortex, Chat, Inbox, Documents) is one application with routes; everything less frequent — Profile, Scopes, Users, Tools, Insights, Runs — remains a separate HTML entry. The division follows usage frequency, not an architectural idea: §3.
- Focus on visual overviews (Inbox, Process-Tree, Document list) and on Chat as a live experience.
- Gateway for non-technical co-users within a Tenant.
What the Web-UI explicitly is not (v1):
- Not a real-time status mirror of the entire backend world — live updates are exclusively in Chat. Other editors show the state at the time of page load and are manually refreshed.
- No cross-tab state sharing. Each browser tab is isolated.
- No offline capabilities, no Service Worker, no PWA manifest.
- No dedicated Auth system. Exclusively JWT from the
/brain/{tenant}/access/{username}endpoint.
The deliberate asymmetry to the CLI: vance-foot has live updates for everything via WebSocket. The Web-UI only has this for Chat. This is v1 scope and may be extended in subsequent versions.
2. Architecture
2.1 Repository Layout
repos/vance/client/ (Workbench symlink: vance-wb/client_web)
├── pnpm-workspace.yaml
├── package.json (Root, devDeps + Workspace Scripts)
├── tsconfig.base.json (common compiler options)
├── tsconfig.json (Project References)
└── packages/
├── generated/ @vance/generated
├── shared/ @vance/shared
└── vance-face/ @vance/vance-face
Workspace Tool: pnpm. No Turbo, no Nx, no build orchestration outside of pnpm’s own filters (pnpm --filter @vance/shared build). Build order results from dependencies entries.
Reference: nimbus-wb/client_world implements the same pattern (without the Engine part) and serves as a practical template.
2.2 Package Responsibilities
| Package | Scope | Content |
|---|---|---|
@vance/generated |
DTOs from Java | TypeScript interfaces, generated by the Maven plugin from vance-api. Manually maintained index.ts. No logic. |
@vance/shared |
Client Logic (UI-free) | JWT storage, refresh flow, REST client, WS connection manager, persistence helpers. No Vue, no Tailwind. Reusable for a later Electron/Mobile client. |
@vance/vance-face |
Web-UI | Vue 3 + Vite + Tailwind + DaisyUI. A Workbench entry with routes (vue-router) plus one HTML entry per single page — see §3. Components and Composables reused internally. |
2.3 Dependency Rules (strict)
| Package | May depend on |
|---|---|
@vance/generated |
nothing (no runtime deps) |
@vance/shared |
@vance/generated |
@vance/vance-face |
@vance/shared (transitively @vance/generated) |
@vance/vance-face must not contain direct REST or WebSocket code. All network calls go through @vance/shared. This ensures @vance/shared remains the sole location for Auth header setting, reconnect logic, and URL constants.
3. Editor Inventory and Channel Mapping
The interface is divided into two classes, and the boundary is the frequency with which a person switches to them.
The Workbench — Landing, Cortex, Chat, Inbox, Documents — is one application (index.html) with routes. One jumps between these five all day; each jump used to be a full page load, including a broken WebSocket, which caused the presence of that user to flicker for everyone else. As routes, a switch costs nothing.
The Standalone Pages — Profile, Scopes, Users, Tools, Tool Templates, Setting Forms, Insights, Runs, Connected Accounts, OAuth Providers, the Addon Area — each remain a separate top-level HTML. One switches to these a few times a day, and the complete teardown upon leaving is an advantage there.
The Login (login.html) is deliberately neither one nor the other: it is the only interface that must boot if the other cannot. It carries no Addon manifest, no Kind registry, no WebSocket, and no Tenant — an error in the Workbench bundle thus locks no one out.
Naming Rule: anything attached to / is named index.html. The Workbench serves /, so it bears this name; the Login, which loses /, is named after what it does. This is not cosmetic — the nginx directives index and try_files already point there, so the refactoring has not touched the server configuration.
The binding list:
| Editor | Address | Channel | Live | Purpose |
|---|---|---|---|---|
| Login | /login.html |
REST | – | Tenant/User input, Token mint, redirect to ?next=. Own bundle — see above. |
| Landing | / |
REST | – | Workbench start page: remembered documents, editor list, Addon tiles (filtered by UI level). Route, not a separate entry — it is the hub for switching between editors. |
| Chat | /chat |
WS + REST (History) | ✅ | Session Chat with the Brain. Picker and Live Mode (see §6). One WS connection per tab. Streaming response. History snapshot on mount via REST. |
| Inbox | /inbox |
REST | – | Three-column layout (Sidebar: Inbox / Tags / Archive / Team Inbox per Team · Item List · Item Detail with type-specific Action Bar — APPROVAL: Yes/No · DECISION: option-buttons · FEEDBACK: textarea+Send · plus Insufficient-Info / Undecidable / Delegate-Modal / Archive / Dismiss). Team Inbox shows items of other team members (own items live in “Inbox”). |
| Documents | /documents |
REST | – | Multi-Project Explorer: Project Sidebar + Folder Navigation (virtual paths) + File Metadata List. Does not render bodies — line click opens the document in Cortex. Multi-select (files and folders) with bulk actions (ZIP export streamed, move, trash — folder ops server-side chunked/cursor-based with progress/cancel), per-line “⋯” menu (unpack ZIP, rename, delete), Finder Drag&Drop upload and “+ Folder” (virtual). |
| Cortex | /cortex |
WS + REST | ✅ (Chat) | Unified Chat + Document + Execute workspace per Project. Entry from Chat via “Open in Cortex” as well as directly from the file explorer. File Tree + Tabs with dynamic Doc-Type Dispatch (kind-registry + hand-rolled Bindings) + persistent Chat Panel with Help Sub-Tab. kind: application manifests open directly as an immersive App View (Calendar / Kanban / Slideshow Federation Bundles via application:<type>-Kind-Registry entry) — no separate entry anymore, see doc-kind-application §7. Run button for .js/.py with live log; Validate + Hactar as JS-only actions. Spec: cortex.md. Replaces the former scripts.html, which has been removed without replacement. |
| Profile | /profile.html |
REST | – | Self-service profile of the logged-in user: identity, per-user preferences (webui.* + chat.language + display.timezone), Speech Settings, Team memberships (read-only) as well as admin-triggered actions (Model Catalog Refresh/Discover). Details in §3.1. |
Rule: New editors follow the same pattern. If a new editor needs live updates, it becomes WS-capable — and it must be justified why REST is not sufficient. Without justification, it remains REST.
Where a new editor belongs: in the Workbench if one regularly switches from it to another of the five; otherwise, a separate page. The question is not “is it important”, but “is it used interchangeably”.
3.0a What the Workbench Requires
Five rules, each born from an error that would recur without them.
1. The router owns the path, the editor owns its query — but writing is done by the router. Each editor manages its URL state itself (Cortex its open tabs, Chat its session, Inbox the selected thread). What is in the query is decided by it; how it gets into the address is not: pushUrl/replaceUrl from platform/navigate.ts. A raw history.pushState(null, …) overwrites the state block that vue-router maintains on each history entry — after that, the address bar shows one thing and the router remembers another, and the back button restores its memory. Measured: leaving Chat with a session and returning resulted in /chat without a session, and Chat came up empty.
2. The router does not filter the query. Whoever navigates builds the target URL and is the authority over what is in it. A filter “discard editor-specific parameters on jump” was once built in and broke three paths because it discarded precisely the parameters that one editor intentionally sends to another (doc=, create=1, createDraft=1, sessionId=). An outdated parameter is an error at the call site.
3. Nothing that remains in the Workbench uses window.location. Jumps between the five editors go via navigateTo, links remain real <a href> and intercept simple left-clicks with handleShellLinkClick — modified clicks never, cmd-click means “new tab”. Both fall back to a real navigation without a router, which is the correct behavior on standalone pages.
4. Addons go over the Bridge. A federated Addon cannot import the host’s Navigate module; vanceNavigate() from @vance/shared is the way (pattern like configureVanceWs). Unlike the WS bridge, it has a fallback to location.href, i.e., to the old behavior — an Addon outside the Workbench continues to function.
5. What is registered once is unregistered. A session now lasts a workday instead of a navigation; a forgotten unsubscribe lives on for that long. The WS singleton ends with the tab (pagehide in wsConnectionStore), not with a component — a component that closes it tears it down on every route change.
And a reset must be built, because none happens incidentally anymore: a failed route chunk reloads (after a deploy, the old session points to filenames that no longer exist), a version change is offered instead of enforced, and login.html remains the guaranteed hard reset.
Consequence: @vance/shared/ws is only imported by the Chat editor. Other editors do not even pull the WS lib into the bundle (Vite/Rollup performs tree-shaking per entry).
3.1 Profile Editor (profile.html)
The self-service profile page (ProfileApp.vue). Authorization model: exclusively the user’s own profile — each endpoint resolves the subject from the JWT claim; there is no username path parameter. Cross-user management runs separately via /brain/{tenant}/admin/users (requires ADMIN). The page exclusively uses the mandatory shell + mandatory components from §7 (EditorShell, VCard, VInput, VSelect, VCheckbox, VButton, VAlert).
Sections (VCards):
| Section | Controls | Persistence |
|---|---|---|
| Identity | tenantId + name (read-only), Display Name (title), Email (email) |
PUT /profile (batch with Save button; status/roles are not changeable here — no self-escalation) |
| Preferences | UI Language (webui.language), Assistant Language (chat.language), Timezone (display.timezone), Theme (webui.theme), Surface/UI Level (webui.uiLevel), Open documents in new tab (webui.document.openInNewTab) |
per control immediately via PUT/DELETE /profile/settings/{key} |
| Speech & Audio | Voice (webui.speech.voiceUri), Rate (webui.speech.rate), Volume (webui.speech.volume) — only visible if the browser supports SpeechSynthesis; voices are filtered by the resolved chat.language |
per control immediately via PUT/DELETE |
| Actions (admin) | “Refresh AI Model Catalog” (POST admin/ai-models/refresh), “Discover AI Models” (POST admin/ai-models/discover) |
direct trigger; the server enforces Action.ADMIN, client-gating is deliberately permissive (a 403 is displayed as an error). See recipes / Model Catalog. |
| Teams | List of team memberships | read-only |
REST Contract (all under /brain/{tenant}/profile, Subject = JWT User):
GET /profile→ProfileDto(tenantId,name,title,email,teams,webUiSettings).webUiSettingscontains allwebui.*keys plus the allowlist extra keys (chat.language,display.timezone).PUT /profile— patch identity (title,email), bodyProfileUpdateRequest.PUT /profile/settings/{key}— write a single setting, bodyProfileSettingWriteRequest{value}.DELETE /profile/settings/{key}— delete setting.
Self-Service Allowlist: PUT/DELETE settings/{key} only accepts keys with webui. prefix or from the extra allowlist (chat.language, display.timezone). Anything else → 400. This prevents the profile page from becoming a backdoor to arbitrary user-scope settings (which AdminSettingsController secures with Admin permission).
Conventions:
- Default = Delete. The default value of a setting is stored as its absence:
theme=auto,uiLevel=standard, UI language “Browser Default” (""),openInNewTab=true→DELETE. A specific non-default →PUT. Keeps cookie and DB lean. - Cookie Refresh. Each mutation renews the server-side (non-HttpOnly)
vance_datacookie, from whichgetActiveTheme/getActiveLanguage/getActiveUiLevelread on the next page load; Theme/Locale are additionally applied immediately in the DOM, without reload. - Timezone — Browser Default Seed. If
display.timezoneis not set on first load, the page persistently stores the browser zone once (Intl.DateTimeFormat().resolvedOptions().timeZone), so that headless turns (Scheduler, Auto-Wakeup) and the Current Date block also get the actual zone — analogous to the Foot/timezoneseed. The selector lists the full IANA list (Intl.supportedValuesOf('timeZone')). Semantics + Consumers: settings-system (display.timezone), prompt-caching §5b, scheduler §10c.
4. REST/WS Division
WS Architecture: Client WS on
/brain/{tenant}/wswith Multi-Channel Envelope (LiveEnvelope, v1 onlysession-channel active). Wire format and cross-pod routing are separately specified in live-ws.md; the WS sections in this Web-UI document here focus on browser lifecycle and editor shell integration.
4.1 General Rule
WebSocket is the primary channel of the backend — vance-foot (CLI v2) is WS-only and the entire CLI functionality is accessible via WS. REST endpoints are an additive convenience layer for the Web-UI:
- REST may duplicate functionality that is also available in parallel via WS.
- REST is the preferred channel for static lists, large payloads, inventory queries without a live aspect.
- WebSocket remains the only channel for streaming, live status, push events, and everything the CLI client needs.
- In case of conflicts, WS wins — new backend features are designed WS-first, REST is optionally added later.
4.2 Specific Endpoints (v1)
REST (Brain → Web-UI):
POST /brain/{tenant}/access/{username}— JWT Mint (exists)POST /brain/{tenant}/refresh— JWT Refresh (new, see §5)GET /brain/{tenant}/admin/settings/...— Settings CRUD (exists)GET|PUT /brain/{tenant}/profile+PUT|DELETE /brain/{tenant}/profile/settings/{key}— Self-service profile (identity + per-user settings), see §3.1GET /brain/{tenant}/sessions/{sessionId}/messages— Chat History Pull for the Chat Editor (new, see §6.5). Also used by the Picker for Last Message Preview.GET /brain/{tenant}/sessions/{sessionId}/processes+.../processes/{name}/messages— read-only Think Process preview of any session of the Tenant (Resource.Session+READ). Path behind the 🧵 button per Session card in the Picker: “what’s running there?” without binding the session. Controlling (Steer/Pause/Stop) remains WS-only on the bound session.- Further REST endpoints for editors will be added as needed, each with DTOs in
vance-api.
WebSocket (Brain ↔ Chat Editor):
Complete frame list in specification/public/websocket-protokoll.md §6. The Chat Editor uses a deliberately restricted subset — see §10.
5. Authentication
5.1 JWT Lifecycle
- Mint:
POST /brain/{tenant}/access/{username}with password provides token (AccessTokenResponse:token,expiresAtTimestamp). Exists. - Lifetime: 24 hours (Hardcoded in
AccessController). - Refresh: New endpoint
POST /brain/{tenant}/refresh— see §5.4. Accepts a valid, unexpired token in theAuthorization: Bearerheader and returns a fresh token. - Storage in Browser:
localStorageunder the keysvance.jwt— the token stringvance.tenantId— for/brain/{tenant}/...pathsvance.username— for UI displayvance.activeSessionId— the last active Chat Session (see §6)
localStorage is accepted for v1. XSS hardening via strict CSP headers and no dangerouslySetInnerHTML usage. HttpOnly-Cookie migration is a later option and is not prepared here.
5.2 Usage in REST Calls
@vance/shared/rest automatically sets Authorization: Bearer <jwt> from localStorage. On 401, the wrapper attempts a refresh once; if the refresh fails, localStorage is cleared and redirects to index.html?next=<currentUrl>.
5.3 Usage in WebSocket
JWT is included in the HTTP upgrade request (header or query parameter — already implemented in BrainAccessFilter). The WS handshake validates the token; expiration during an active connection does not lead to disconnect, but new frames may be rejected by the server. The Chat Editor polls the token expiration date locally and triggers a refresh and a reconnect before expiration.
5.4 Refresh Endpoint (new)
Request: POST /brain/{tenant}/refresh, Authorization: Bearer <currentJwt>, empty body.
Response: RefreshTokenResponse { token, expiresAtTimestamp } (same form as AccessTokenResponse).
Errors: 401 if token expired or Tenant mismatch, 403 if Tenant in path ≠ tid-claim.
Associated DTO in de.mhus.vance.api.access:
RefreshTokenResponse— structurally likeAccessTokenResponse, own class for endpoint symmetry and cleaner TS generation. Annotated with@GenerateTypeScript("access").
No request body and thus no RefreshTokenRequest — the endpoint identifies the caller exclusively via the Authorization header token.
6. Session Handling in the Chat Editor
The Chat Editor operates in two clearly separated modes. Picker Mode (URL without sessionId) shows a selection of own and project sessions, without a WS connection being bound to a session. Live Mode (URL with sessionId) binds the WS connection to exactly one session and streams chat. The switch happens via routing within the same HTML entry.
Reason for the sub-page separation: before binding, the Picker list with bound-flags (see websocket-protokoll.md §5.1) must be visible so that the user recognizes an occupied session without first getting a 409 resume attempt. Additionally, the Picker URL is bookmarkable as an entry point, and deep links to a specific session as a second form.
6.1 Persistence in the Browser
localStorage keys that the Chat Editor reads and writes:
vance.activeSessionId— the last actively called Session ID (for auto-redirect from the Picker)vance.lastPickerProject— last selected project card in the Picker (for reorder/highlight)
A Session ID in localStorage is not authoritative state, but only a UX hint for Picker pre-selection. The Source of Truth is always the session-list response from the server.
6.2 Picker Mode (/chat, without sessionId)
On mount:
- Check JWT. If expired / missing: Redirect to
login.html?next=.... - Connect WS — the connection remains in the unbound state.
- In parallel: Query
project-listandsession-list(without filter). - Render
<EditorShell>with Picker View. Layout:- Sidebar: Project List (DataList). First element is the pseudo-group “Personal” with the user’s own session
_user_<username>(auto-created on first click if not yet existing). Below that, all visible projects. - Main: Sessions of the selected project (Card List). Per session visible:
displayNameorsessionId-suffix as title,lastActivityAtrelative (“3h ago”),bound-status as<VStatusDot variant="red">with tooltip “occupied by another client”, otherwise gray (= available). Last bound session is at the top. - Footer of the Main column: Button “New session” (Primary) and optional Recipe Dropdown — passed as
recipeName-param tosession-bootstrapon click.
- Sidebar: Project List (DataList). First element is the pseudo-group “Personal” with the user’s own session
- Selection of an available session → Routing to
/chat?sessionId=<id>(within the same Vue app, no full page reload). Selection of an occupied session is disabled; an additional “Force resume” path is not planned for v1. - “New session” click →
session-bootstrapwith the active project + selected Recipe → Server assignssessionId→ URL update.
The Picker View is a separate Vue sub-view within the Chat Editor bundle, not a second HTML entry — Vue Router (or a simple watch on URLSearchParams) is sufficient.
6.3 Live Mode (/chat?sessionId=<id>)
On mount:
- Check JWT as in 6.2.
- Connect WS.
- Send
session-resumewith the URLsessionId. - Server responses:
- Success: WS is bound, Connection Dot turns green. Parallel REST call for Chat History (see §6.5), then
process-progressandchat-message-*Live Frames are welcome. - 404 / 403: Session no longer exists / foreign → User-facing error toast and redirect to the Picker (remove
?sessionIdfrom URL). - 409 (already bound): Connection Dot turns red. UI shows a banner action in the Right Panel slot “Session occupied — pick another / wait” with a Live Re-Try button (new
session-resumeattempt every ~10s optionally). The basic path remains: User goes back to the Picker.
- Success: WS is bound, Connection Dot turns green. Parallel REST call for Chat History (see §6.5), then
- Update
vance.activeSessionIdwith the successfully bound ID.
WS Disconnect during runtime (network down, server restart): Connection Dot turns gray, re-connect attempts in the background with backoff. On successful re-connect, another session-resume — if the session lands on 409 in the meantime (e.g., another tab took it over), the dot jumps to red with the same banner action as above.
Multiple tabs on the same session: Prevented by the bound-flag — the second tab gets a 409. Multiple tabs on different sessions are explicitly allowed; each tab maintains its own WS connection.
6.4 Connection Dot — Three States
The <VStatusDot> component in the <EditorShell> Topbar (right next to the User Menu) is the only display of the connection status. Three states, uniform for all WS editors (v1 only Chat):
| Color | Meaning | Tooltip |
|---|---|---|
| 🟢 green | WS connected, Session bound, Live Stream active | “Connected — live” |
| ⚪ gray | Picker Mode (unbound) OR Disconnect with Re-Connect attempt | “Reconnecting…” / “Pick a session to start” |
| 🔴 red | Last bind attempt resulted in 409 (occupied by another connection) | “Session is occupied” |
No yellow state and no movement in the dot component — animation is distracting. Pure color change is enough.
6.4a Inbox Badge in the Topbar
<InboxBadge> is located in the <EditorShell> Topbar to the left of the Process Badge and shows the number of pending Inbox entries for the logged-in user — so that a waiting question is visible on every editor page, not just after opening the Inbox.
- Data Source:
GET /brain/{tenant}/inbox/count→InboxCountResponse { pending, requiresAction }. The server counts in Mongo; the badge never pulls the item list (bodies/payloads) just to read.length. TheassignedToparameter has the same grammar asGET /inbox(missing = personal Inbox). - Color: neutral-outline as long as only pure outputs (shares, notes) are pending,
warningas soon asrequiresAction > 0— i.e., a process is actually waiting for a response. - Invisible when 0 — unlike the Process Badge, which remains as the sole entry point to the process list even at zero; the Inbox has its own landing tile.
- Refresh Points: Page mount, tab refocus (
visibilitychange), and every Inbox mutation in the Inbox editor. Deliberately no live push: v1 keeps live updates in the Chat editor (§3–§4), andinbox-item-addedonly reaches a socket with a bound Chat session. A live badge outside of Chat would be a spec extension, not a catch-up.
6.5 Chat History vs. Live Stream
The Web-UI renders two separate data sources in the Chat Editor:
| Source | Channel | Frame / Endpoint | Content | Rendering |
|---|---|---|---|---|
| History | REST | GET /brain/{tenant}/sessions/{id}/messages?limit=N (new, see below) |
persisted ChatMessageDocuments, loaded once on mount |
Static in main scroll, top to bottom |
| Live Stream | WS | chat-message-stream-chunk + chat-message-appended |
Streaming tokens and new persisted messages | Appended to the end of the main scroll; chunks optimistically, append frame replaces them |
| Live Progress | WS | process-progress (metrics/plan/status) |
ephemeral status pings, no conversation content | Right Panel Slot, NOT in Chat Stream — see user-progress-channel.md |
History and Live Stream must not overlap during the transition: chat-message-appended frames whose message ID is already in the loaded history are deduped by the client (ID match). Optimistic stream chunks belonging to a non-existent message are buffered and replaced on the first chat-message-appended.
Raw Thoughts (“Gedanken”). For reasoning models (Qwen3, DeepSeek-R1, GPT-OSS), the live channel streams the raw thought narration during the turn; on chat-message-appended, the chunk is replaced by the final content (from the action message) and the narration would otherwise disappear. To keep the thoughts readable, the backend encapsulates the raw narration verbatim (unfiltered, including <think> markup) and persists it separately as a thinking-field (see llm-resource-management §4.1 and websocket-protokoll §6) — available on both the History ChatMessageDto and ChatMessageAppendedData. <MessageBubble> renders thinking (if not empty) as an expandable, by default collapsed “Gedanken” area below the response text (native <details>), verbatim (no Markdown render, so <think> markup is not swallowed by the sanitizer). Responses without streamed free text (e.g., pure action call) do not provide thinking → no area.
New REST Endpoint (to be implemented in vance-brain, spec then moves to websocket-protokoll.md §0 or a separate rest-protokoll.md):
GET /brain/{tenant}/sessions/{sessionId}/messages?limit=200&before=<messageId>— Page through Chat History backwards, newest first. Auth: JWT Bearer like all REST endpoints. Response:List<ChatMessageDto>(new DTO invance-apiwith@GenerateTypeScript("chat")). Fields:messageId,processId,role(user/assistant/system/tool),content,createdAt, optionalthinking(raw thought narration, verbatim — see above), optionaltoolCallId/toolNamefor Tool Bubbles.
Reason against a WS-based History Pull: REST is for static lists with paging (§4.1), and the Picker preview can use the same endpoint — a snippet of the last message per session is shown as a sub-title in the Picker. WS pull would burden the connection without a live aspect.
6.6 Slash Command Subset for the Web-UI
In vance-foot, slash commands are interpreted locally and sent as WS frames to the server. On the web, this is server-side identical (frames are the same) — the question is only which commands the Web-UI offers to the user in the input line.
Allowed in Web v1:
| Command | Affects |
|---|---|
/help |
purely client-side, lists the following commands |
/clear |
scrolls UI; does not delete server state |
/skill <on\|off> <name> |
maps to process-skill |
/compact |
maps to process-compact |
Not allowed in Web v1 (would be lifecycle commands managed by the Picker or the EditorShell menu):
/session-create,/session-resume,/session-list,/logout/process-create,/process-list,/process-steer(steering is normal input without/)/connect,/disconnect(WS connection is editor-implicit)
User input without / goes directly as process-steer to the active Think Process of the session. The Bootstrap (Recipe / Engine) is parameterized once in the Picker and sent by the client as session-bootstrap — it is not a user command.
6.7 URL Schema — Overview
| URL | Mode | Behavior |
|---|---|---|
/chat |
Picker | §6.2 |
/chat?sessionId=<id> |
Live | §6.3, with bookmark and deep-link suitability |
/chat?projectId=<id> |
Picker with pre-selected project | optional, Sidebar selection is set |
7. UI Style Guide
Basic Assumption: A style guide as plain Markdown text is not enough — as soon as three editors are developed in parallel, the appearance drifts. Consistency is enforced here by a mandatory shell and a mandatory component library, not by discipline alone. Direct use of DaisyUI classes outside the component library is a code review block.
7.1 Theme
- Tailwind 3 + DaisyUI, configuration analogous to
nimbus-wb/client_world/packages/controls/tailwind.config.js. - Themes:
lightanddark. Default: System Preference (prefers-color-scheme). Toggle in the Topbar. - Accent Color: DaisyUI default
primaryfor v1. Custom branding will be determined later. - Typography: Inter for UI text, JetBrains Mono for code/IDs/token displays.
- Spacing: Tailwind default scale (4px grid).
- Global CSS:
packages/vance-face/src/style/app.css— imported by everymain.ts. Tailwind directives, DaisyUI theme selection.
7.2 Mandatory Shell — <EditorShell>
Every editor (except login.html, which does not know a Tenant before login) must render its top-level layout via <EditorShell>. No one builds their own <header> or <main>.
┌─────────────────────────────────────────────────────────────┐
│ Topbar: [Logo][Crumbs Project › Session › ...] [WS] [User] │
├─────────┬───────────────────────────────────────────┬───────┤
│ │ │ │
│ Sidebar │ Main Content │ Right │
│ (opt.) │ (Editor-specific) │ Panel │
│ │ │ (opt.)│
├─────────┴───────────────────────────────────────────┴───────┤
│ Footer (opt., spans full width — e.g. chat composer) │
└─────────────────────────────────────────────────────────────┘
Usage:
<template>
<EditorShell title="Inbox" :breadcrumbs="['Project foo', 'Inbox']">
<template #sidebar><InboxFilters /></template>
<InboxList />
<template #right-panel><InboxItemDetails /></template>
<template #footer><InboxReplyComposer /></template>
</EditorShell>
</template>
<EditorShell> renders the Topbar with fixed geometry (Logo left, Breadcrumbs next to it, optional Connection State Dot, User Menu with Tenant/Logout/Theme Toggle right) and a CSS Grid body with up to four cells — Sidebar (Slot #sidebar), Main (Default), Right Panel (Slot #right-panel), Footer (Slot #footer, spans full width). A cell only exists in the grid if its slot is occupied. Geometry, spacing, and transitions are centralized in the component — no editor adjusts them.
7.2.1 Focus Model — Single-Focus-Zone
Conceptual spec, designer API, and comparison with related layout patterns:
specification/public/responsive-focus-layout.md. This section here only describes the<EditorShell>API.
EditorShell operates a Single-Focus Model: exactly one zone (Sidebar, Main, Right, Footer) is “in focus”, the others are “not in focus”. The focused zone gets more space and a light background; the unfocused ones shrink to a compact width and adopt the editor background (DaisyUI base-200). This always makes it clear where the user is currently working, without zones needing to be expanded/collapsed.
Props to <EditorShell>:
| Prop | Type | Default | Meaning |
|---|---|---|---|
focusModel |
'off' \| 'auto' |
'off' |
off = columns have fixed width, no background highlight, no reclaim handles. auto = focus mechanism active. |
focusZone |
'main' \| 'sidebar' \| 'right' \| 'footer' |
'main' |
Currently focused zone. Only effective if focusModel='auto'. |
Triggers (belongs to focusModel='auto'):
pointerdownon a zone → this zone is focused.focusinon the Footer (or a focusable element within it, e.g., the Composer textarea) → Footer is focused.focusininstead ofpointerdownbecause a tab-in from outside via keyboard should also address the Footer.Escapeglobally → Focus back tomain(unless already there).pointerdownoutside the Editor Body Grid (Topbar, everything around it) → Focus back tomain. The Main zone is the implicit “Home”.
Reclaim Handles — small chips at the edge of each unfocused optional zone. They remain clickable even if the zone has collapsed to width 0 (e.g., on phone viewports), and are thus the only way back into a hidden zone. When a zone is focused, its own handle is hidden (opacity: 0).
| Zone | Handle Glyph | Position |
|---|---|---|
| Sidebar | › |
right edge of the Sidebar (between Sidebar and Main) |
| Right | ‹ |
left edge of the Right zone (between Main and Right) |
| Footer | ▴ |
top edge of the Footer (between Main row and Footer row) |
| Main | — | no handle (Main is always reclaimable via Escape or Topbar click) |
Reclaim Handles are the only form of “manual focus operation” — they replace toggle icons in the header or keyboard shortcuts in v1.
Zone Sizing — defined as CSS variables in the scoped style block of <EditorShell>, allowing tuning without code edits. Three values exist per zone: --sidebar-base (default, when zone not focused), --sidebar-expanded (when focused), --sidebar-collapsed (when a sibling zone is focused that pushes the Sidebar away — e.g., when Right is fully open and Sidebar has to make space). Analogous for --right-* and --footer-*. For viewport responsiveness, values may use clamp(min, vw-fraction, max).
Responsive Collapse on narrow viewports — via media queries, --right-base / --sidebar-base / --footer-base can be set to 0, so that the zone completely disappears when Focus≠its-name. Reclaim Handles remain visible and are then the only way to reopen the zone.
Background Highlight — all zones by default show DaisyUI base-200 (= Editor BG, “gray”). The focused zone switches to base-100 (“white”), with a 200ms transition-colors. Transitions on background color and grid track width run synchronously (common --focus-duration-CSS variable).
Transition Status (2026-06-01): Pilot in /chat runs within ChatView.vue (own CSS Grid, own CSS variables, only Right Reclaim Handle), not via EditorShell props. The Shell Lift transfers the model to EditorShell and adds Sidebar and Footer Handles. Until then, focusModel/focusZone on EditorShell have code-side effect (column width), but no listeners/handles/background highlights — editors other than Chat should leave focusModel='off' (Default).
7.3 Mandatory Components
All UI elements are built using the following primitives. No editor directly uses DaisyUI classes like btn, input, alert, card, dialog, select, textarea, file-input. Only Tailwind layout classes (flex, grid, gap-*, p-*, space-*, min-h-*) are allowed — these are layout, not style.
Two locations, and the separation is addressability for Addons: the V*-primitives plus CodeEditor and FormFields are in their own workspace package @vance/components (packages/components/src/) — they must therefore not know anything from vance-face (no stores, no composables), otherwise they cannot be loaded in a federated Addon area. Everything that needs application internals is in packages/vance-face/src/components/ (EditorShell, MarkdownView, KindBox, SessionHeader, …). The index.ts there re-exports @vance/components so that existing @components imports in vance-face continue to resolve; an Addon area imports the primitives directly from @vance/components, never via an application’s barrel (reason — bundle size: §9).
Status: ✅ = implemented and exported. – = planned, will be created as soon as the first editor needs it. The Location column indicates from which of the two directories the component comes (components = @vance/components, face = packages/vance-face/src/components/).
Layout & Navigation
| Component | Status | Location | Wraps | Purpose |
|---|---|---|---|---|
<EditorShell> |
✅ | face | – | Top-level layout: Topbar (Logo, Title, Breadcrumbs, optional Connection Dot, User Menu with Logout) + optional Sidebar (#sidebar) + Main (default) + optional Right Panel (#right-panel) + optional Footer (#footer, full-width) + optional Topbar Slot (#topbar-extra). Focus model via focusModel/focusZone props — see §7.2.1. |
<VBackButton> |
✅ | components | – | Consistent back button for sub-pages, with arrow icon. Expects an @click listener; does not use its own routing logic. |
<VSideTabs tabs sync-hash> |
✅ | components | DaisyUI tabs |
Vertical tab bar. modelValue is the active tab ID; syncHash mirrors it in location.hash, making tabs deep-linkable and enabling Back/Forward between them. |
Form Inputs
| Component | Status | Location | Wraps | Purpose |
|—|—|—|—|—|
| <VButton variant size href loading> | ✅ | components | DaisyUI btn | Only allowed button. Variants: primary | secondary | ghost | danger | link. Renders as <a> if href is set (anchor-as-button for editor navigation). Own loading prop with spinner. |
| <VInput> | ✅ | components | DaisyUI input | Text input with label, help text, error state. Mandatory for all single-line inputs. |
| <VTextarea> | ✅ | components | DaisyUI textarea | Multi-line plain text input. font-mono by default. For structured content (Markdown / JSON / YAML) use <CodeEditor>. |
| <CodeEditor mime-type> | ✅ | components | CodeMirror 6 | Multi-line editor with syntax highlighting. Language selected via mime-type: text/markdown, application/json, application/yaml (plus common aliases). Other mime types fall back to plain text. Line numbers, folding, bracket matching, undo/redo active. Mandatory for all editors that edit code/config/Markdown content — VTextarea remains for pure plain text. |
| <VSelect> | ✅ | components | DaisyUI select | Dropdown with Optgroup support: options: { value, label, group?, disabled? }[]. Consecutive options with the same group land under an <optgroup>. Generic over the value type (string \| number). |
| <VCheckbox> | ✅ | components | DaisyUI checkbox | Checkbox with label. |
| <VToggle v-model label title> | ✅ | components | DaisyUI toggle | Switch for a boolean state. label is optional — a toggle directly next to the action it changes often needs no words; without label, title is mandatory (otherwise the lever is only readable by its author). |
| <VRange v-model min max step size> | ✅ | components | DaisyUI range | Slider for a numeric value. |
| <VFileInput multiple accept> | ✅ | components | DaisyUI file-input | Drag-and-drop zone with file picker as fallback. v-model is always File[] (even for multiple={false}: 1-element). Multi-mode appended (dedup by name+size+lastModified), picker reset after selection. Per-file ✕-remove + “clear all”. |
| <VTagEditor v-model max-tags max-tag-chars> | ✅ | components | – | Tag input as pills with ✕. Caps mirror backend constants in SessionService (default 20 tags / 50 characters) — the limit is at both ends so the UI doesn’t offer what the server rejects. |
| <VColorPicker v-model allow-clear> | ✅ | components | – | Chip row for an accent color value from the AccentColor vocabulary. allowClear adds a “no color” chip. |
| <VEmojiPicker v-model> | ✅ | components | emoji-picker-element | An emoji as an icon value (Workpage icon, App tile). |
| <VLinkPicker> | ✅ | components | – | Link selection in two tabs: Project Document (server-side search across all kinds, emits vance:/<path> with the kind as a hint) and direct URL. Five tabs in the app variant — see inter-links. |
| <FormFields fields v-model> | ✅ | components | – | Renders a FormFieldDto-list as a form — the shared engine behind Wizards, Setting Forms, and Document Templates. The vance-face version is a thin wrapper that contributes the host language’s labels. @vance/components never imports vue-i18n — where a component there needs its own translation, it uses useT(), which reads the host’s $t from the app context (mechanism: agent/face.md, “i18n across the Federation boundary”). |
| <MarkdownView source inline> | ✅ | face | marked + DOMPurify | Read-only Markdown renderer. GFM-enabled (Tables, Task Lists, Fenced Code). Math rendering via KaTeX — recognizes $...$, $$...$$, \(...\), \[...\] and renders them as set formulas (see inline-and-embedded-content §11.7.1). DOMPurify sanitizes the HTML output — mandatory for any user/LLM content that comes into the DOM via v-html. inline-prop for one-line previews (Chat Bubble, List Row Preview). In block mode, a token walker dispatches Vancetope-specific Fenced Blocks and vance:-Markdown links to <InlineKindBox>/<EmbeddedKindBox> — see inline-and-embedded-content §11. |
| <KindBox> / <InlineKindBox> / <EmbeddedKindBox> | ✅ | face | — | Frame for rich content artifacts. Inline = Fenced Body with Kind Tag ( mindmap`, `tree, ` list`, `records). Embedded = Markdown Link with vance:-URI to a Document. Both channels use the same registry kind→View. Action Buttons: Inline = Download + Raw (Toggle); Embedded = Copy + Open. Full spec: [inline-and-embedded-content](/specs/inline-and-embedded-content). |
|
Containers & Dialogs
| Component | Status | Location | Wraps | Purpose |
|---|---|---|---|---|
<VCard title> |
✅ | components | DaisyUI card |
Uniform padding/shadow/rounding. #header/default/#actions slots. |
<VModal v-model close-on-backdrop> |
✅ | components | DaisyUI <dialog> |
Two-way bound visibility. ESC + Backdrop click (optional via Prop) + X button close. #header/default/#actions slots. Primary action right, Cancel left. |
<VDropdown position trigger-variant menu-class> |
✅ | components | DaisyUI dropdown |
Context menu on a trigger slot. Opening direction bottom | top | end. |
Feedback
| Component | Status | Location | Wraps | Purpose |
|---|---|---|---|---|
<VAlert variant> |
✅ | components | DaisyUI alert |
Banner. Variants: info | warning | error | success. |
<VEmptyState headline body> |
✅ | components | – | Centered, #icon + Headline + Body + #action slot. |
<VToast> |
– | – | – | Auto-dismiss notification (top right). 3s success, 5s error. Global stack via useToast() composable. |
<VLoading> |
– | – | DaisyUI skeleton / loading |
Skeleton for lists, spinner for inline actions. |
Display
| Component | Status | Location | Wraps | Purpose |
|---|---|---|---|---|
<VBadge variant size outline soft> |
✅ | components | DaisyUI badge |
Status marker. soft mixes the variant color only a few percent into the page background — thus remains readable in both themes. |
<VShareButton> |
✅ | components | – | Icon button that hands an entry to Milliways (“show this to someone”). Located here instead of in each app because three apps want the same thing (search result, feed entry, link entry). The contract is the ShareSubject; everything after the click is Milliways logic — see milliways-system. |
<VStatusDot variant> |
– | – | – | Colored dot for Connection/Process status. Variants: green | grey | red (no yellow, no animation — see §6.4). Tooltip slot for hover text. Mandatory in the Topbar slot of <EditorShell> for every WS editor. |
Lists & Paging
| Component | Status | Location | Wraps | Purpose |
|---|---|---|---|---|
<VDataList items selectable selectedId @select> |
✅ | components | – | Card list with default slot per item. Optional selectable (cursor + hover + emit select). For Sessions, Processes, Documents — wherever metadata should be visible. |
<VPagination v-model:page page-size total-count> |
✅ | components | – | Zero-based page indicator with « ‹ N/M › »-buttons and “X–Y of Z” display. v-model emits update:page with the new page number. |
<VDataTable rows columns> |
– | – | – | Table for Settings, Recipes, Audit Logs (column structure dominant). |
<VTree node> |
– | – | – | Hierarchical display — only for Marvin Task Tree. |
New primitives are added here before they are used in an editor. If someone builds an element that only they need, they do not build it as a primitive — they build it in their editor directory. If a second editor needs the same thing, it becomes a primitive.
Shared Domain Building Blocks (not a primitive, but also not editor property — exactly the case from the sentence above, occurred):
| Component | Location | Purpose |
|---|---|---|
<ChatSidePanel> |
face (src/chat/) |
An agent conversation in a side panel. Owns the Session Bind, the Takeover/Occupied states, the optional Client Tool Attach, and the ChatView/ChatComposer pair. All host-specific data comes as props (boundDocumentId, boundDocSelection, activeApp, activeInbox, currentFileSource, draftKey); the component reads no store. |
<SessionPickerPanel> |
face (src/components/) |
Session list of a project for a Right Panel slot, with inline Recipe Modal for “new session”. Emits open-session; what opening means is decided by the host. |
The reason for <ChatSidePanel> is not reuse of Chat markup, but that almost nothing about it is Chat: it is the WebSocket lifecycle — the tool attach that must wait for the server-confirmed bind, the reconnect that swaps the socket underneath, the “open elsewhere” fork. A second version of it would be a second version of the same bugs. Consumers today: Cortex (with Help tab, document context) and Inbox (without Help tab, with activeInbox instead of document context — see maximegalon-system.md §8).
7.4 Enforcement
- Code Review: PRs are checked against style drift. Direct DaisyUI classes (
btn,input,alert,card,dialog,modal,select,checkbox,textarea,file-input,badge,tabs,navbar,toggle,range,dropdown) outside ofpackages/components/src/andpackages/vance-face/src/components/are a block. This also applies to Addon areas (vance-addon-*/client/) — they have no third allowed location, they import from@vance/components. - Editor Template:
packages/vance-face/src/_template/contains a skeleton (_TemplateApp.vue+main.ts) that correctly integrates<EditorShell>. New editors are copied from it. (Create as soon as the second editor is developed — oversized before that.) /preview.html: Own editor HTML as a visual reference, showing every primitive in every state (loading/empty/error/normal). Maintained when adding a new primitive. Serves as a smoke test for theme and DaisyUI updates. Due — the library has reached 25 components in@vance/components, a central reference page helps with reviews and DaisyUI updates.- ESLint Rule (escalation stage, not v1):
no-restricted-syntaxblocks direct DaisyUI usage outside the two allowed directories. Will be activated if review discipline is insufficient.
7.5 Dialogs — Modal vs. Sub-Page vs. Side-Panel
| Variant | Component | When | Examples |
|---|---|---|---|
| Modal | <VModal> |
Confirmations, quick edits ≤ 5 fields, tool approvals. Not bookmarkable. | “Delete session?”, “Set setting”, “Approve tool call” |
| Sub-Page | own route, same <EditorShell> |
Longer editors, wizards, anything where the user should be able to share the URL. | Document editor with multiple tabs |
| Side-Panel | #right-panel-slot of <EditorShell> |
Contextual details, main view remains usable. | Process Inspector, Inbox Item Details |
Back Button on Sub-Pages: <VBackButton> top left in the content area, not in the Topbar (which remains static). Browser back must work the same way.
7.6 States
| State | Component | Behavior |
|---|---|---|
| Loading | <VLoading variant="skeleton\|spinner"> |
Skeleton in lists, spinner inline |
| Empty | <VEmptyState> |
Centered, Icon + Headline + Action |
| Error transient | <VToast variant="error"> |
Auto-dismiss 5s |
| Error permanent | <VAlert variant="error"> |
Remains, closable (e.g., WS disconnected) |
| Success | <VToast variant="success"> |
Auto-dismiss 3s |
7.7 Forms
- Labels above the field via
<VInput label="...">. Help text and errors go into component props, not the template. - Inline validation (field level). Submit button (
<VButton variant="primary">) is disabled until all required fields are valid. - Form Footer (Modal or Form Card): Cancel left (
<VButton variant="ghost">), Primary right.
7.7.1 Cancel / Apply / Save — Mandatory Pattern for Edit Dialogs
Edit dialogs (Modal and Sub-Page if the page is a specific detail editor card) use three footer buttons with clearly separated semantics:
| Button | Variant | Effect |
|---|---|---|
| Cancel | ghost, left |
Discards changes since last Apply / Open. Exits the dialog. |
| Apply | secondary, center |
Persists changes, stays in dialog. On server error, dialog also remains — error is displayed in dialog. |
| Save | primary, right |
Persists + exits dialog (== Apply ▶ Cancel-on-success). On server error, dialog remains, error message appears same as with Apply — user can correct and press Save or Apply again. |
Implementation Convention:
async function apply(): Promise<boolean> {
// … persist; set editError on server error;
// returns true if successful.
}
async function save(): Promise<void> {
if (await apply()) closeDialog();
}
This keeps the logic DRY: save = apply + close-on-success. The dialog component / editor decides what “close” specifically means (Back-To-List, Modal-Close, Side-Panel-Collapse).
Why the separation: Those who edit iteratively (“change title, see if it looks good, change path, see if the list shows it correctly”) want Apply. Those who are finished and want to return to the overview press Save. Before this convention, it was a Save button with unclear behavior — some editors closed, some didn’t; this confused users.
Does not apply to:
- Pure confirmation modals (e.g., “Delete?” → Cancel + Confirm). A single action button is sufficient there.
- Tool Approval modals (e.g., “Allow Tool X to run?” → Cancel + Run). Single-shot.
- Wizards with Next/Back navigation. Own convention.
7.8 Refresh Convention for REST Editors
Every REST-only editor has a refresh trigger — either as an icon button at the top of the content area or as a <VButton variant="ghost"> with a refresh icon. F5 is always a valid alternative, as editors load their state on-mount.
7.8a Project Memory per Browser Tab
Each editor has its own project selection, and each had its own idea of “nothing selected”: the document explorer took the alphabetically first project, server tools and setting forms opened on _tenant, run view and chat picker on nothing at all. Switching editors therefore meant clicking the same project again every time.
platform/lastProject.ts therefore stores the last edited project — in sessionStorage, i.e., per browser tab. This is the counter-decision to the sidebar collapse state, which is on the server (me/ui-state/sidebar): that is a permanent preference, this is the place where the reader currently is. Two tabs are routinely in two projects — that’s what tabs are for —, so a shared storage would create exactly the disturbance that the feature is supposed to eliminate. sessionStorage also survives full page loads between standalone page editors, and that is the entire range to be covered. The key carries tenant and login, so that an account change in the same tab inherits nothing.
Three rules:
- Explicit overrides remembered. A
?project=in the URL, a session that brings its project, a deep link — all of these mean “specified differently”. Recall only answers the question of what should apply if no one has said anything. - Remembered is the effective selection, no matter how it came about — even the one hydrated from the URL. Whoever follows a deep link into a project works there; the next editor should open there.
- Checked against the list that the host actually offers (
recallProject(names)), not against the tenant list: the Setting Forms picker hides the_user_*-hub projects, and a value not in the dropdown would leave the field empty. A remembered project can also have been deleted or renamed.
When a remembered project may replace a built-in default: if this default is still reachable in the same selection. Setting Forms permanently lists “tenant-wide” in the dropdown, Insights “all projects”, Scopes the tenant node — there, the default is one click away and recall wins. Server Tools is the exception: its _tenant is only the starting value, the sidebar has no line for it, so a remembered project would make the tenant-wide tool defaults unreachable. This page remembers the selected project, but continues to open on _tenant. For the same reason, the _tenant-sentinel is never remembered: it is the defaults layer, not a project someone works in — storing it would displace the real one.
Empty is not deleting: rememberProject(null) is a no-op. Editors constantly maintain “no project” as an intermediate state (the chat picker gives up its selection as soon as a session binds), and none of these means the reader has left the project.
Consumers: Cortex, Documents, Chat, Run View, Insights, Scopes, Setting Forms (Recall + Remember) and Server Tools (only Remember).
7.9 Printing — data-print-root
An editor that should be printable marks one subtree with the attribute data-print-root. Everything else is handled by the global print layer style/print.css (imported from app.css, thus active on every entry):
- Every ancestor of the print area hides branches that are neither the area nor contain it (
:has([data-print-root])) — Topbar, Sidebar, Right Panel, Composer disappear, without an editor naming them individually. - The shell geometry is resolved:
EditorShellis anh-screen overflow-hidden-grid with scroll regions within it, and a browser prints only the visible section from a scroll container. Without resolving this, one would get exactly one screen instead of the entire content. .no-printon screen controls that are within the area (hover buttons, suggestion ghosts, transient messages)..print-onlyis the opposite direction: a body that only exists for paper.- For the duration of the print job, the Light Theme is enforced (
ensurePrintLightThemeinplatform/themeWeb.ts, hooked tobeforeprint/afterprintplus theprint-media query for Safari). Dark mode otherwise reaches the printer as light text on a fill that the print pipeline discards — i.e., white on white.
A page without data-print-root prints unchanged: the :has() selectors hit nothing.
Only whoever is the page claims the print area. A component that runs both as a page and as a side panel must not set the attribute permanently — it would otherwise hijack Cmd+P where it is only an accessory and hide the actual target. ChatView is exactly this case (Chat Editor, Cortex Panel, Inbox Panel) and therefore gets a printable prop, which only ChatApp sets. In Cortex and Inbox, the print layer remains inert and the page prints as before.
Consumers (v1):
- Chat History (
/chat) — the complete conversation including all kind representations, without a shell around it. - Cortex (
DocumentTabShell) — the document, never the chat panel, even if it is open. Each visible body marks itself, so what the reader is currently looking at is printed: the rendered view, the image, the code preview. Only one tab is mounted at a time, so there can never be two print areas.
Edit mode is the special case, and a silent one. CodeMirror only keeps the lines around the viewport in the DOM — printing the editor would mean losing two-thirds of a long file, without the printout showing anything. Cortex therefore, in the source code case, renders the full text from the model into a .print-only-<pre> that carries the print area (printsSource). This is the exception to “no second render path” — it exists because the alternative would be an incomplete printout that looks complete.
Lazy-loaded content must orient itself to the print area. Measured against Chrome’s print pipeline: what a beforeprint-handler changes synchronously (or in a microtask) still ends up in the printout — a setTimeout(…, 0) already does not. A fetch that starts at print time therefore never reaches the running job; a beforeprint-hook at best fixes the second printout. The decision must be made beforehand, and the signal is in the DOM: sitting inside a [data-print-root] is the declaration “this is for paper”. LinkCard therefore resolves its preview there on the first idle, even unseen; outside a print area (Cortex and Inbox chat panel), it remains with the IntersectionObserver. The price is one preview request per link instead of only for the scrolled ones — the Brain caches them for 7 days. New lazy renderers follow the same rule.
No dedicated print button, no print route.
8. Build and Dev Workflow
8.1 DTO Generation
The generate-java-to-ts-maven-plugin (see vance/plugins/generate-java-to-ts-maven-plugin/) is configured in vance-api/pom.xml. Output directory is relative from the module:
<plugin>
<groupId>de.mhus.vance.tools</groupId>
<artifactId>generate-java-to-ts-maven-plugin</artifactId>
<execution>
<phase>generate-resources</phase>
<goals><goal>generate</goal></goals>
<configuration>
<inputDirectory>${project.basedir}/src/main/java</inputDirectory>
<outputDirectory>${project.basedir}/../../client/packages/generated/src</outputDirectory>
</configuration>
</execution>
</plugin>
Path justification: vance-api/ is under repos/vance/server/vance-api/, target is repos/vance/client/packages/generated/src/ — hence ../../client/... (server → vance → client).
Convention:
- Plugin overwrites
.tsfiles per annotated Java class with Auto-Generated-Header. - Plugin does not delete outdated files. If a Java DTO is renamed or deleted, the old
.tsfile must be manually removed. - Plugin automatically generates cross-file imports: for each referenced type that is also present in the model (e.g.,
MaximegalonTypereferenced byMaximegalonDto), animport { ... } from './…'is set. The scanner reads the resolved TS types (not the raw Java types), so that mappings likeMap<K, V>→Record<string, V>do not create phantom imports on the key type. TS built-ins (string,number,Date,Record,Promise, …) are excluded. - The
index.tsinpackages/generated/src/index.tsis manually maintained and re-exports the DTOs that a consumer actually needs. Extended when adding new editors. - Nimbus example:
nimbus-wb/server/world-shared/pom.xmlandnimbus-wb/client_world/packages/shared/src/generated/.
8.2 Build Commands
Java Side (Maven):
cd vance/vance-api && mvn install # Generates DTOs as a side effect
Frontend Side (pnpm):
cd repos/vance/client
pnpm install
pnpm --filter @vance/generated build # tsc → dist/
pnpm --filter @vance/shared build # tsc → dist/
pnpm --filter @vance/vance-face build # vite build → dist/
# or shorter:
pnpm -r build
wb-Wrapper: wb build face performs four steps:
mvn -pl vance-api,<all vance-addon-brain-*> -am install -DskipTests— exists only to regenerate the TypeScript DTOs. The Addon list comes from a glob, so a new Addon is automatically included; tests of the modules pulled in via-am(vance-shared,vance-brain) are irrelevant for the face bundle and therefore do not run.cd repos/vance && pnpm install && pnpm -r build- EE Addon areas (
repos/vance-ee, own pnpm workspace) — only if vance-ee is checked out, and after step 2: they link@vance/sharedand@vance/componentsfrom the vance checkout, whosedistmust exist. pnpm -r test— the vitest suites run with the face build.
Java devs do not need Node installed — the frontend build is only triggered by wb build face. wb build vance continues to build only Java.
8.3 Dev Server
cd repos/vance/client
pnpm --filter @vance/vance-face dev # vite dev with HMR
Vite proxies API calls to the local Brain (http://localhost:9990 or configurable via VITE_BRAIN_URL). Configuration analogous to nimbus-wb/client_world/packages/controls/vite.config.ts.
8.4 Vite Configuration (Entries)
packages/vance-face/vite.config.ts declares one Rollup input per HTML file. The list is there and is deliberately not transcribed here — a spec that copies a configuration drifts from it, and that was exactly the case for this passage for years.
What is binding are the three properties of the file:
indexis the Workbench, not one of many pages. The four editor routes do not appear there as inputs; they are lazyimport()s insrc/shell/router.ts, so that the Shell entry remains small and an editor is only loaded upon entry.- No history fallback needed. Vite’s
appTypedefaults to'spa'and servesindex.htmlfor every extensionless path —/cortexis answered by the Dev Server itself. IfappType: 'mpa'is set, this changes and routes need their own rewrite rule. - Addons come at runtime, not via config:
remotesis empty, registration is done byregisterRemotes()from the manifest (§ Addon System). A new Addon does not require a config entry.
9. Editor Convention
A route in the Workbench is a component, not an entry:
packages/vance-face/
├── index.html the Workbench (one entry for all routes)
└── src/
├── shell/
│ ├── main.ts Boot: Auth, Kind Registry, Addon Manifest, Router
│ ├── router.ts Path → Component; the rules from §3.0a
│ └── ShellApp.vue <RouterView> plus session-wide Chrome
└── <name>/<Name>App.vue the route itself — lazy loaded from router.ts
A standalone page retains the old structure because it needs it:
packages/vance-face/
├── <name>.html Top-level HTML with <script type="module" src="/src/<name>/main.ts">
└── src/<name>/
├── main.ts Auth check → mount Vue app
└── <Name>App.vue Root component
Sub-components and sub-views live in src/<name>/components/ and src/<name>/views/. Shared components (Topbar, Sidebar, Modal Wrapper) are in src/components/, shared logic in src/composables/.
An entry that only needs a handful of primitives imports them directly from @vance/components instead of via the application’s @/components-barrel. The barrel re-exports MarkdownView, among other things, and an application cannot declare itself side-effect-free (@/platform/bootWeb is imported precisely because of its side effect) — it is therefore structurally not tree-shakable. Measured: login.html thus dropped from 609 to 399 KB. For route chunks, it is irrelevant, as they share the chunk anyway.
Vue-Router: in the Workbench, one for all routes (src/shell/router.ts). Within a standalone page, allowed but not mandatory — pages with internal tabs/sub-views can bring their own, single-view pages forgo it.
9.1 Boilerplate main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { ensureAuthenticated } from '@vance/shared/auth';
import App from './<Name>App.vue';
import '@/style/app.css';
await ensureAuthenticated(); // Redirect to login.html if no valid JWT
const app = createApp(App);
app.use(createPinia());
app.mount('#app');
9.2 Auth Check in @vance/shared/auth
export async function ensureAuthenticated(): Promise<void> {
const jwt = localStorage.getItem('vance.jwt');
const exp = parseExp(jwt);
if (!jwt || exp < Date.now()) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login.html?next=${next}`;
return new Promise(() => {}); // Block further execution
}
}
10. WebSocket Frames in the Chat Editor
specification/public/websocket-protokoll.md §6 is the source spec; the Web-UI uses the following existing frames, without introducing new top-level types:
| Who | Frame | Purpose in Chat Editor |
|---|---|---|
| Client → Server | session-bootstrap |
Picker → Live transition: Session create-or-resume + initial Recipe / Engine |
| Client → Server | session-resume |
Direct resume on deep link (URL ?sessionId=...) |
| Client → Server | session-unbind |
Tab closes cleanly, without ending the session |
| Client → Server | process-steer |
User input to the Think Process — Chat send path |
| Client → Server | process-skill, process-compact |
Few allowed slash commands (see §6.6) |
| Server → Client | chat-message-stream-chunk |
Streaming tokens; optimistic rendering |
| Server → Client | chat-message-appended |
Persist commit; replaces optimistic chunks |
| Server → Client | process-progress |
Side-channel updates (metrics / plan / status) — Right Panel, not Chat Stream |
| Server → Client | inbox-item-added, inbox-item-updated |
Optional, if Chat Editor shows an Inbox Badge in the Topbar |
| Server → Client | error |
Bind errors (see websocket-protokoll.md §5.1) and frame-specific errors |
Web clients do not use client-tool-register / client-tool-invoke / client-tool-result — Workspace Tools are CLI/Desktop domain.
New needs arise from §6.5 not in the WS protocol, but in the REST layer (GET /brain/{tenant}/sessions/{id}/messages).
11. What Comes Later (explicitly not v1)
- Cross-Tab Sync via BroadcastChannel or SharedWorker.
- Live updates in Inbox/Process/Session editors.
- API versioning in the WS Welcome Frame.
- HttpOnly-Cookie-Auth.
- Service Worker, Offline, PWA.
- Own Branding/Custom Theme.
These points are deliberately removed from v1. If a need arises, a v2 section will be added here.
12. References
nimbus-wb/client_world/— pnpm Workspace, Multi-Entry Pattern,tailwind.config.js,vite.config.tsnimbus-wb/server/world-shared/pom.xml— Plugin configuration as templatevance/plugins/generate-java-to-ts-maven-plugin/— the generator itselfspecification/public/websocket-protokoll.md— existing WS framesspecification/public/architektur-scopes-clients.md— Scope hierarchy and Client modelCLAUDE.md— will be supplemented with Web-UI module structure and dependency rules