Files
Skald-Circle/dev-docs/frontend.md
T
Daniele 027d815b66
Nightly Build / build (push) Canceled after 10m54s
feat(viewer): preview word documents (.docx/.doc/.odt/.rtf) as PDF
The file viewer converts word-processor documents to PDF server-side via
LibreOffice (skald_core::docx::DocxConverter), mirroring the LaTeX pipeline
but content-hash cached: the format is self-contained, so there is no
dependency graph and the file watcher needs no expansion. Container-only
documents are shuttled out and converted on the host. With no LibreOffice
installed the viewer says so and falls back to download-only. Downloads
still save the original document, not the preview PDF.
2026-09-08 16:30:13 +01:00

23 KiB

Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md

Read this when: you touch anything under web/ — components, chat tabs, routing, i18n, theme, the security-group picker.


Frontend components (web/components/)

All extend LightElement from web/lib/base.js (Lit). ChatSession (web/lib/chat-session.js) is the shared base for WS-connected chat UIs.

The chat is the home page. <app-copilot> is a single persistent element with two layout modes driven by the route (llm-page-change): mode="full" on the home route (it fills the workspace — the conversation IS the landing page, with a welcome hero + prompt suggestions as its empty state) and mode="dock" on every other route (the classic resizable side panel). Same element ⇒ WS, tabs, scroll and drafts survive navigation; you watch files/projects update live while the conversation keeps going. Collapse only applies to the dock. The old dashboard content (hero, LLM stats charts, pending inbox, quick guide) lives on as the separate #dashboard page; the debug toggle moved to the Settings page.

Two kinds of tab, and the difference is what a tab names. A primary tab is a source: it shows whatever web / project-7 currently points at (sources.active_session_id), which is also where background delivery lands — notify, a finished async task, an inbound Telegram message — and what a /new moves to a fresh row. At most one per source; a project's Open chat always lands on it and never mints a conversation (provision_session(reset:false)). A secondary tab is one specific conversation, opened with +: its source points elsewhere, so it is unreachable by source name and is addressed by id everywhere — REST, WebSocket, event filtering. Nothing is delivered to it from outside. POST /api/sessions/new creates one without touching sources, which is the entire difference from POST /api/sessions (a reset). Its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context.

The queue and the model pin are keyed by conversation, not by source (ChatHub.inboxes: HashMap<i64, ConversationInbox>, selected_clients: HashMap<i64, String>). This is the load-bearing half: two tabs on one source would otherwise serialize into one queue and one turn, and share a /model pin — while the security group was already per-session and persisted, so the pin was the odd one out. The source-taking methods survive as one-line resolvers (send_messagesend_message_to_session, and _for_session twins for context/cost/compact/mcp/model/cancel/resume/upload), so Telegram, mobile and cron are untouched. Cost of the rekey: queues now grow with conversations-talked-to-since-boot rather than with the four-or-five sources, so a reset retires the queue it replaces (retire_inboxConversationInbox::close, consumer breaks) instead of leaving a parked task forever.

Events are filtered per conversation (ge.session_id == Some(session_id)), which is why anything a chat must see has to carry a session id — an untagged GlobalEvent now reaches nobody. Two emitters had to be fixed for exactly that: show_file_to_user's OpenFile (the tool takes a session_id from handler.session_id via the interface-tools builder) and revalidate_security_groups, which now returns (session_id, source, group). The inbox lifecycle events (Approval*/Clarification*/Elicitation*) stay the deliberate exception and go to every connection, since they carry ids only and drive the sidebar badge. A primary WS connection additionally follows NewSession for its source — re-binding session_id and its handler mid-loop — so a second window doesn't keep talking to a conversation another window just reset; a session-addressed one ignores it, having been pinned on purpose.

The tab bar is server-side state; the selection is not. Which conversations the copilot shows survives a reload through chat_sessions.is_open (owner table, additive via ensure_column) — GET /api/sessions/open restores them (computing primary per row, since only sources knows), PUT /api/sessions/{id}/open opens/closes one, PUT /api/sessions/{id}/title renames one (title predated all this and was dead; an empty title stores NULL, so the rename box is also the undo). It is deliberately not localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. Which tab is selected stays in sessionStorage (copilot-active-tab), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) is_open defaults to 0 and chat_sessions::create never sets it — every /new leaves its predecessor behind and every system-agent pass mints a row, so DEFAULT 1 would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset moves the flag: provision_session(reset) mints a new row, so POST /api/sessions returns the new id and the new_session event carries it, and _bindTabSession closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens before super.connectedCallback() (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS.

Theme (web/css/variables.css): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (--radius-sm/md/lg), 16px-base chat type, WCAG-fixed contrasts, global :focus-visible ring and prefers-reduced-motion support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet.

i18n (web/lib/i18n.js + web/i18n/{en,it,fr}.js): t(key) helper, I18nMixin re-renders on locale-changed. Resolution order: user preference (users.locale, editable on the profile page) → instance default (registry config key ui_locale, editable by the admin in Settings — declared in skald_core::i18n::config_set) → English. Server-side, never re-implement that chain: skald_core::i18n::resolve_locale(pool, user_locale) is the one function (with default_locale(pool) and language_name(locale) for prompt rendering); they read through db::config because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes ui_locale via skald_core::i18n::set_default_locale (no system bus exists there), the web setup page sends locale to POST /api/setup/user, which writes it through GlobalConfigManager::set. Supported locales are centralized in skald_core::i18n::SUPPORTED_LOCALES and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).

Plugin & backend i18n — two seams, both keyed the same way. A plugin page fragment (served from its own router) localizes client-side: it ships a web/i18n.js module (export default { en, it, fr }, keys namespaced plugin.<id>.<key>) and calls addStrings(dicts) (in web/lib/i18n.js) once at module load to merge into the host's shared DICTS, then uses the same t()/I18nMixin as the app (the fragment imports them from the absolute /lib/i18n.js — the same module instance the host uses, so t() and locale-changed are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: common.js registers the dict + re-exports t, and MobileBase extends I18nMixin(LitElement). Backend-generated strings (a plugin's HTTP error/response text, notifications) go through core_api::i18n: a plugin declares Plugin::i18n() -> Vec<LocaleBundle> (mobile-connector loads them from embedded i18n/{en,it,fr}.json via include_str!), the PluginManager merges every plugin's bundles once at boot into an I18nCatalog (skald_core::i18n) and injects it as PluginContext.i18n: Arc<dyn I18nApi>. At request time the handler resolves the caller (Caller.user_id from the auth layer) and calls i18n.for_user(user_id, key, args).await — which reads users.locale, runs it through the same resolve_locale chain, and renders locale → en → key with {name} placeholders. The frontend surfaces these already-translated: jf() throws the server's response text verbatim. Front and back keep separate tables (UI labels ≠ error strings; overlap is minimal) but share the plugin.<id>. namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far.

Role-driven interface (§0.1 — data, not enums): roles.attrs JSON may carry "ui_mode": "simple". /api/auth/me resolves it via RoleAttrs (admin is always full) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. MeResponse also carries locale, default_locale and encrypted.

Security-group picker (per-session, runtime, role-gated). A security-group is a permission bundle only — a tool_permission_groups id, driving tool visibility/approval — not a "mode" (no system-context injection; the RunContext.system_prompt substrate exists but is unused by the picker). The role carries the user's allowed set (default permission_group + attrs.permission_groups, §0.1); a new non-project session inherits the role's default group (sessions.rs::createrole_default_run_context). The chat surface switches it at runtime like the model pill: copilot.js renders a shield pill (hidden when ≤1 group) fed by GET /api/my/security-groups (the caller's role set, joined with group names; admin → all); selecting one sends the WS control message {type:"select_security_group", group} (chat-session.js::_selectGroup, twin of select_client). The server (ws.rs::handle_select_security_group_msg) validates against the role, persists it on chat_sessions.run_context, updates the live handler, and broadcasts ServerEvent::SecurityGroupSelected so every open tab re-syncs (the initial state is sent on WS connect). Enforcement is server-side via the shared run_context::validate_run_context_for_role (used by both the WS path and the REST set_session_run_context): a non-admin may only pick a group in its role's effective set (else 403), and every other RunContext field (system_prompt, allow_fs_writes/allow_fs_reads, working_directory) is discarded — closing an fs-escalation hole; admin passes through unchanged.

Selection is gated once; the persisted group is re-checked on every load. validate_run_context_for_role runs at selection time, and the result is persisted on chat_sessions.run_context — so on its own it let a group survive the role that granted it, indefinitely and across restarts (revoke ops from a role, and every session that had already picked it kept running on it). The fix is a second, narrower seam: run_context::reconcile_group_for_user, run by ChatSessionManager::get_or_create_handler on every handler build, which treats the stored group as advisory and degrades it when the owner's current role no longer allows it. Three properties are load-bearing: (a) it degrades to the role's default group (role_default_group, the same seam sessions.rs uses for a new session, so start-group and fallback-group cannot drift) — never to None, because a missing group means the catch-all default, whose rules are the fallback tier under every other group, so clearing widens; (b) it touches only security_group, unlike the selection path, so a project session's server-built project_root/system_prompt survive a permissions edit; (c) on uncertainty (unknown user, unreadable role, DB error) it leaves the stored group alone — guessing could only widen. The liveness half is Skald::revalidate_security_groups_for_{user,role}, called synchronously from the roles API (update) and the users API (role reassignment), which reconciles already-open handlers, persists, and emits SecurityGroupSelected so the pill re-syncs. Same rule as revocation: authorization is pushed, never left to the bus.

The role editor (roles-page.js) sets the default group + an allowed-groups checklist (→ attrs.permission_groups) + a default-assistant select (→ attrs.chat_agent) fed by GET /api/agents filtered to type:chat minus project-coordinator (source-driven); the same exclusion is enforced server-side in the roles API (validate_chat_agent).

The page shell — a new page renders narrow until it is sized (web/css/page-shell.css)

The trap, in one sentence: a custom element the browser has never heard of is display: inline, and every page host in this app is a display: flex container — so a page nobody wrote a CSS rule for becomes a content-sized flex item and renders as a narrow column in the middle of the workspace. It looks like a broken stylesheet inside the page; it is the absence of a rule about the page. <models-tts-section> shipped this way — added to the Models hub beside its three siblings, never added to the sizing block that listed them by name.

Two independent things have to be true, and both were violated at some point:

  • The host needs flex-direction: column. Every page host is toggled display: noneflex by its own component (this.style.display = this._open ? 'flex' : 'none'), which means the value in CSS is always none and the flex direction is never obvious from reading the block. In the default row, a child is sized by its content on the main axis; in column, the cross axis stretches and the child fills the width no matter what it declares. Several pages hid this for years behind a width: 100% on their own root <div> (.pv-page, .page-panel, .apr-page, .llmr-page) — which works, but only defends the one page that remembered it. .llm-page, shared by all four Models sections, carries max-width: 100% and no width, which is why the section with no host rule collapsed and its three siblings did not.
  • The child needs flex: 1; min-height: 0; min-width: 0. Otherwise it fills the width but not the height, and a wide table inside it pushes the whole workspace row wider than the viewport.

The fix is a descendant selector, not a longer list. The three multiplexer pages (tasks-page, projects-page, models-hub-page) each render exactly one sub-section at a time as their only child, so page-shell.css sizes them as tasks-page > *, projects-page > *, models-hub-page > *. The enumeration was the bug: a list of element names is a thing to forget, and forgetting it is silent — no console error, no failed build, just a narrow page. A new section now inherits the sizing by existing. plugin-page-host > [plugin-id] had already reached the same conclusion for plugin-contributed fragments.

So: adding a page to the app is two edits, not one. Write the component, and give the element a rule — display: none + flex-direction: column + flex: 1 + min-width: 0 — in page-shell.css or the page's own stylesheet. If it hangs off one of the three multiplexers, the > * rule already covers it and you write nothing. To check the whole set at once, look for a host that sets style.display = 'flex' in web/components/ and has no matching element selector in web/css/.

File Element Notes
copilot.js <app-copilot> The chat surface (_wsSource='web'): full/dock roving layout, welcome hero empty state, privacy chip, composer with model pill, slash-command autocomplete
shared/chat-page.js <chat-page> Mobile chat (_wsSource='mobile')
copilot-render.js (helpers) renderMsg, renderTool, renderDiff, etc. — shared by copilot and chat-page
sidebar.js <app-sidebar> Nav sidebar; role-driven (ui_mode); inbox badge is live — the chat WS forwards the inbox lifecycle events (approval_requested/resolved, clarification_*, elicitation_*) regardless of source, chat-session.js re-dispatches them as the inbox-changed window event, and the sidebar (+ agent-inbox.js) refreshes on it; a 60 s poll remains as fallback
topbar.js <app-topbar> Top nav bar; per-user avatar color hashed from the username
dashboard-page.js <dashboard-page> #dashboard — status hero, LLM stats charts, pending inbox, quick guide
shared/file-viewer-base.js FileViewerBase (base) Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX/word-docs, watcher, _renderBody); driven by _show/_hide. Extended by desktop + mobile
file-viewer-page.js <file-viewer-page> Desktop file viewer: FileViewerBase + hash routing via window.openFile(path)#file_viewer?path=...
shared/file-viewer-mobile.js <mobile-file-viewer-page> Mobile file viewer: FileViewerBase + prop-driven (visible/path), full-screen with back button
agents.js <agents-page> Agent discovery and config
agent-inbox.js <agent-inbox-page> Pending approvals + clarifications from background sessions
approval-rules.js <approval-rules-page> Approval rule management
cron-jobs.js <cron-jobs-page> Scheduled job management
connectors.js <connectors-page> MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the Add connector dropdown (Marketplace / manual form at #connectors/new), per-row removal from the catalog, and the Sign-in providers modal (§7/§14/§15)
plugin-catalog.js <plugin-catalog> #plugins — admin status board: one card per plugin (enable toggle + health dot + Configure → #plugin-detail)
plugin-detail.js <plugin-detail> #plugin-detail?id=<id> — one plugin's admin page: instance-config form (config_schema) + a read-only roster of who holds it, linking to #users/{id} (plugin twin of connector-detail.js)
users-page.js <users-page> #users list + #users/{id} one user's page: Profile, Connectors, Plugins, Security. Both grant sections are the single write path for "what may this person use"
plugin-page-host.js <plugin-page-host> Host for plugin-contributed pages (#plugin/<plugin_id>/<page_id>): dynamic-imports the fragment module, registers its element, mounts it with plugin-id
system-agents.js <system-agents-page> #system-agents — one tab per background agent (plus "All"): its description, its settings (admin only) and the caller's own run history. Everyone sees the page; only an admin gets the config half
shared/config-form.js ConfigFormController The schema-driven settings form, shared by config-page.js and the System agents page — one renderer and one write path (PUT /api/config/{key}) for every ConfigSet
shared-folders.js <shared-folders-page> #shared-folders — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's __SHARED_FOLDERS__ context
projects/ <projects-page> #projects — host + list + board; the board is tabbed (Files explorer with live watcher + write actions, Sharing members), deep-linked #projects/{id}[/sharing]. See the Projects section
files-page.js <files-page> #files — the caller's whole space. Level 0 is the virtual root (GET /api/files/roots), level 1 the shared <file-explorer>. See the Files section
shared/file-explorer.js <file-explorer> The explorer itself, host-agnostic: root + rootLabel + optional rel, can_write read from the listing. Used by #files and the project board
connector-detail.js <connector-detail-page> A connector's own page (#connector?name=X): env/secret form + Test, the OAuth login panel (sign in → paste code → complete, §15), global enable. Access grants live only on the Users page (users-page.js — the #users/{id} page's connectors section, with the plugin grants right below it), so "who has what" has a single surface
shared/connector-common.js (helpers) Shared Connectors vocabulary: statusOf (incl. needs_login for a pending OAuth row), STATUS_LABEL, schema normalization, jf fetch
llm-providers.js <llm-providers-page> LLM provider management
models-hub.js <models-hub-page> Models hub landing (LLM / Transcription / Image / TTS); renders one section at a time, sized by the models-hub-page > * rule
models-llm.js <models-llm-section> LLM model CRUD + drag-and-drop priority
models-transcribe.js <models-transcribe-section> Transcription model CRUD
models-image.js <models-image-section> Image generation model CRUD
models-tts.js <models-tts-section> Text-to-speech model CRUD
mobile-app.js <mobile-app> Mobile app shell
shared/settings-page.js <settings-page> Mobile settings: per-user avatar, locale picker (I18nMixin), profile/preferences

Server-rendered kinds in the file viewer (LaTeX, word documents)

Two kinds are not served as-is but rendered to PDF server-side on demand: .tex/.latex (kind latex, ?compile-latex=true, latexmk) and .docx/.doc/.odt/.rtf (kind docx, ?compile-docx=true, LibreOffice — skald_core::docx::DocxConverter, content-hash cache, no dependency graph). Both render through the same <pdf-view> and degrade gracefully when the host tool is missing (501) or the run fails (422): the viewer fetches the flagged URL, keeps the error body, and falls back. Three asymmetries between the two, each deliberate:

  • Fallback content. A failed LaTeX compile still shows the source (readable); a word document is a zip, so its fallback is the binary download state with the reason in a foldable block on top — there is no source to show.
  • Download. A .tex downloads the compiled PDF (the source is useless to most people); a word document downloads the original file — it is itself the editable artifact someone asking "send me the document" wants, and the PDF is only the preview mechanism.
  • Watching. A .tex subscription expands server-side to its .fls dependency set (file_watch.rs); a word document is self-contained, so the plain per-file watcher already covers it and a change re-converts via the content-keyed cache — file_watch.rs needed no branch. Container-only word documents still convert (the API shuttles the bytes out, see filesystem-and-containers.md) but, like any container-only path, they are not watchable.