# Changelog # Changelog Additions — v0.26.0 ## [0.26.0] — 2026-03-10 ### Summary Workflow engine — team-owned staged processes with AI intake, visitor-facing branded entry, human assignment queue, and admin builder UI. Channels become execution contexts that move through defined stages, driven by personas and monitored by team members. ### Added #### Workflow Definitions (v0.26.1) - **Workflow CRUD.** `POST/GET/PATCH/DELETE /api/v1/workflows` with name, slug (auto-generated from name if omitted), description, entry_mode (`authenticated` | `public_link`), is_active flag, branding JSONB, retention JSONB. `UNIQUE(team_id, slug)` with partial index for global workflows. - **Stage CRUD.** `POST/GET/PUT/DELETE /api/v1/workflows/:id/stages/:sid`. Stages have name, ordinal, persona_id, system_prompt, form_template (JSONB), history_mode (`full` | `summary` | `fresh`), assignment_team_id. - **Stage reorder.** `PATCH /api/v1/workflows/:id/stages/reorder` — transactional ordinal update from ordered_ids array. - **Publish + versioning.** `POST /api/v1/workflows/:id/publish` creates an immutable `workflow_versions` snapshot (stages + persona tool grants). `GET /api/v1/workflows/:id/versions/:version` retrieves any published version. Duplicate publish returns 409. Schema: `workflows`, `workflow_stages`, `workflow_versions` (migration 023). - **Permission gating.** Workflow create/update/delete requires `workflow.create` permission (existing permission constant). #### Workflow Instances + Stage Transitions (v0.26.2) - **Start instance.** `POST /api/v1/workflows/:id/start` creates a workflow channel, pins the published version, binds stage 0 persona, sets allow_anonymous based on entry_mode. Returns channel_id + stage info. - **Advance/reject/status.** `POST /channels/:id/workflow/advance` (with optional form data merge), `POST /channels/:id/workflow/reject` (with required reason → system message), `GET /channels/:id/workflow/status`. - **Workflow columns on channels.** `workflow_id`, `workflow_version`, `current_stage`, `stage_data` (JSONB), `workflow_status` (enum: active/completed/stale/cancelled), `last_activity_at` (migration 024). - **Staleness sweep.** Background goroutine (1h tick) marks active workflow instances as `stale` when `last_activity_at` exceeds `WORKFLOW_STALE_HOURS` (default: 72, env-configurable, 0 to disable). - **Stage data merge.** Dialect-safe (Go-side JSON merge, no Postgres `||` operator). Data accumulated across stages in `stage_data` column. #### Visitor Experience (v0.26.3) - **Workflow landing page.** `GET /w/:id/:slug` — public surface with branded layout (accent color, logo URL, tagline from workflow branding JSONB), persona name/icon, "Start" button, session resume link. - **Visitor start flow.** `POST /api/v1/workflow-entry/:scope/:slug` — creates channel + anonymous session, binds stage 0 persona, sets session cookie, returns redirect to `/w/:channelId` (existing chat surface). - **DenyVisitor tool scoping.** Workspace, git, memory tools already use `DenyVisitor` predicate (shipped in v0.26.0 foundation). #### AI Intake + Assignment Queue (v0.26.4) - **`workflow_advance` tool.** AI-callable tool with `RequireWorkflow` predicate — only available inside workflow channels. Persona calls this when form data is collected. Triggers same transition as handler advance. Accepts `data` (JSON object) + `summary` (string for stage note title). - **Form template injection.** Completion handler injects current stage's `form_template` as a system prompt when `channelType == "workflow"`. Tells the persona what fields to collect and when to call `workflow_advance`. - **Stage notes.** `CreateWorkflowStageNote()` persists collected form data as channel-scoped notes (title: "Stage N: {summary}", content: JSON). - **Assignment queue.** `workflow_assignments` table (migration 025): channel, stage, team, assigned_to, status (unassigned/claimed/completed), timestamps. Created automatically when advancing to a stage with `assignment_team_id`. - **Assignment endpoints.** `GET /teams/:teamId/assignments` (team queue), `GET /workflow-assignments/mine` (user's claimed), `POST /workflow-assignments/:id/claim` (optimistic lock), `POST /workflow-assignments/:id/complete`. #### Workflow Builder UI (v0.26.5) - **Admin workflows section.** New "Workflows" category tab in admin surface. List view with name, slug, entry mode, active status, version. Detail view with editable fields, stage list, add/edit/delete stages, form template JSON editor, publish button, public URL display. - **Queue sidebar section.** "Queue" section in chat sidebar (after Channels). Shows user's claimed assignments with badge count. Click to navigate to workflow channel. Complete button per assignment. - **Workflow API methods.** `workflow-api.js` extends global API object with full workflow CRUD, stage management, publish, instance lifecycle, and assignment queue operations. - **Workflow CSS.** Stage row styling (draggable, ordinal badges), queue sidebar items, team queue panel, admin badges. #### Foundation (v0.26.0) - **Session cleanup job.** Background goroutine deletes expired anonymous sessions with no messages. `SESSION_EXPIRY_DAYS` env var (default: 30). - **ExecutionContext TeamID wiring.** `teamID` propagated through completion, stream_loop, and messages handlers into `ExecutionContext`. - **Stale code cleanup.** Surface delete now removes static assets. Fixed stale pricing comment. Removed deprecated `AllDefinitionsFiltered()`. - **`workflow.create` permission.** Already in permission constants. ### Fixed - **Gin route wildcard panic.** `/w/:id` (chat) and `/w/:scope/:slug` (landing) used different param names at the same path depth — Gin panics on registration. Fixed: landing route uses `/w/:id/:slug` (same param name, disambiguated by depth). API visitor start moved to `/api/v1/workflow-entry/:scope/:slug`. - **SQLite surface registry nil panic.** `SeedSurfaces()`, `IsSurfaceEnabled()`, and `EnabledSurfaceIDs()` dereferenced nil Surfaces store on SQLite (no migration 022). Added nil guards with graceful fallbacks. ### Migration Notes - **DB wipe required.** Migrations 023-025 add new tables/columns. Fresh `chat_switchboard_dev` DB recommended for dev deployments. - **New env vars:** `SESSION_EXPIRY_DAYS` (default 30), `WORKFLOW_STALE_HOURS` (default 72). Both optional. - **Frontend files:** 3 new JS files (`workflow-api.js`, `workflow-admin.js`, `workflow-queue.js`), 1 new CSS file (`workflow.css`). Templates updated to include them. ### How to Create a Workflow 1. **Admin → Workflows** tab → "+ New" → enter name 2. **Edit** the workflow: set description, entry mode, toggle Active 3. **Add stages** with names, persona assignments, form templates (JSON), history mode 4. **Publish** to create an immutable version snapshot 5. **Share** the public URL (`/w/{scope}/{slug}`) for visitor intake, or start instances via API (`POST /workflows/:id/start`) ### Known Gaps (v0.27.0) - Team-scoped workflow management UI (team admins can only see workflows via admin panel, not the team settings surface) - Drag-and-drop stage reorder in builder UI - `on_complete` workflow chaining (column exists, nullable, not wired) - Workflow retention policies (column exists, not enforced) - Stage persona auto-switch in chat UI (backend binds persona, frontend doesn't reflect stage transition yet) ## [0.25.4] — 2026-03-09 ### Added - **Admin settings export/import.** Export button downloads versioned JSON envelope (`_type`, `_version`, `_exported_at`, `_switchboard_version`, `settings`, `policies`). Import validates envelope, shows confirmation with key counts and metadata, writes each key via `adminUpdateSetting()`. Sensitive key warning on export. - **Workspace rename/delete.** Workspace picker rows show ✏ rename and 🗑 delete buttons. `API.deleteWorkspace(id)` method added (backend `DELETE /workspaces/:id` already existed). Confirmation dialog warns about permanent file deletion. - **Bulk model visibility.** Settings → Models page shows "X visible, Y hidden of Z total" with **Show All** / **Hide All** buttons. - **Multi-provider live test failover.** `LIVE_PROVIDERS=venice,openrouter` env var. `tryCompletion()` walks providers until one responds 200. `setupAllProviders()` tolerates individual setup failures. CI no longer fails on transient upstream 503s. ### Fixed - **Admin surface: 6 audit fixes.** Stray `>` in back button, dual-bind race on back button, hardcoded People nav flash, `closeAdmin()` ignores return URL, `openAdminSection()` pushes history, Storage section empty (missing `files.js` script). - **Banner overlapping surface content.** Body changed to `display:flex;flex-direction:column;height:100vh`. Surface is `flex:1;min-height:0`. Banners get `flex-shrink:0`. Replaced fragile `calc(100vh - ...)` with proper flex distribution. - **Scaling architecture.** `#surfaceInner` wrapper in `base.html` — generic scale target via `transform:scale()`. No surface-specific selectors. Banners stay unscaled. Extension surfaces get scaling for free. - **Group permissions 500.** Postgres `UpdateBuilder.SetNull()` incremented `argIdx` without adding an arg — positional placeholders misaligned after first NULL column. SQLite version was already correct. - **BYOK/Personas toggles not working.** Admin saves to policies store (`allow_user_byok`), settings loader read from `GlobalConfig` (`user_providers.enabled`). Two different stores, two different keys. Fixed to read from `Policies.GetAll()`. - **Stale settings modal HTML.** 225 lines of orphaned modal body content from pre-surfaces migration rendered directly in chat page flow (General tab with `display:block` always visible below chat input). Removed along with old admin panel modal (~320 lines total). - **Team admin modal transparent.** `class="modal-content modal-lg"` → `class="modal"`. Also fixed save-to-note modal in `notes.js`. Zero `modal-content` references remain project-wide. - **Team admin modal layout.** Duplicate `display` property (`display:none;...;display:flex`) made content always visible. Moved flex to CSS class `.team-admin-content`. - **Team persona create button missing.** Template lacked `settingsTeamAddPersonaBtn` button and `settingsTeamAddPersona` form container that JS expected. - **Settings surface handler wiring.** `_initSettingsListeners()` early-returned on missing `settingsModel` (only on General section), killing all handlers below including BYOK provider form, model visibility, team admin add-member/provider/persona. Restructured: settings features under `data-surface` guard, team admin features unconditional with `?.`. - **Settings surface missing `App.policies` and `App.models`.** On chat surface, `app.js` populates these. Settings surface called wrong API (`getSettings()` returns user prefs, not policies). Fixed to `getPublicSettings()` + `fetchModels()`. - **Model Roles section empty.** Template had no `userRolesConfig` container for the roles section. `loadUserRoles()` early-returned on BYOK disabled or no personal providers with zero feedback. Added proper template section with notice text. - **User settings NULL/null corruption.** `users.settings` column: SQL NULL → Postgres `COALESCE` fix. JSON `null` literal → `NULLIF(settings, 'null')` + `jsonb_typeof` guard. Prevents `null || {...} → [null, {...}]` array concatenation. Both `GetSettings` and `UpdateSettings` fixed for Postgres and SQLite. - **`null.model_roles` crash.** Fresh users have NULL settings → `API.getSettings()` returned null → `settings.model_roles` threw. Added `|| {}` fallback on all callsites. - **Project files 403 for admins.** `ListByProject`, `UploadToProject`, and `loadAndAuthorize` ownership checks had no admin bypass. Added `c.GetString("role") != "admin"` guard. ### Migration Notes - **No new migrations.** All fixes are handler/template/JS changes. - **Data fix required.** Users with corrupted settings (JSON array or null literal) need manual cleanup — see release notes. ## [0.25.3] — 2026-03-09 ### Fixed - **Theme system mode broken.** `Theme.set('system')` called `removeAttribute('data-theme')` → `:root` CSS hardcoded dark → system always showed dark. Now resolves OS preference via `matchMedia`, sets `data-theme` to resolved value, registers listener for OS changes. - **Resize bar jump on click.** `onStart` now pins inline width/minWidth immediately after measuring via `getBoundingClientRect()`. Prevents snap-back during open-transition. - **Debug modal no background/scroll.** `class="modal-content modal-lg"` → `class="modal modal-wide"`. Modal body restructured as flex column with proper overflow. - **Settings/admin back button.** Stashes `document.referrer` in sessionStorage, nav links use `location.replace()`, back button reads stashed URL. - **Profile save errors.** Wrong element ID (`settingsDisplayName` → `profileDisplayName`), wrong API routes (`/users/me` → `/profile`, `/auth/password` → `/profile/password`). - **Profile data empty.** `getUserContext()` only populated ID and Role from JWT. Now fetches full user record. - **Scale slider range.** `max="120"` → `max="175"`. Commit on mouse release, not live on input. ## [0.25.2] — 2026-03-09 ### Added - **Drag-resize utility.** `drag-resize.js` — single primitive for workspace handle and pane split handles. Full-viewport overlay prevents iframes/CM6 from swallowing mousemove. Touch support. Transitions killed during drag. ## [0.25.1] — 2026-03-09 ### Fixed - Minor surface integration fixes and CSS corrections from v0.25.0 deployment. ## [0.25.0] — 2026-03-08 ### Added - **Dynamic surfaces architecture.** Go template engine (`server/pages/`) with `base.html` shell, surface-specific templates, per-surface script/CSS blocks. Surface manifest registry with `SeedSurfaces()` for DB persistence. - **Surface registry.** `surface_registry` table. Admin can enable/disable surfaces. Core surfaces: chat, admin, settings, editor, notes. - **Component extraction.** UserMenu, ModelSelector, FileTree, CodeEditor, NoteEditor extracted as standalone components with Go template partials + JS hydration. - **Pane container.** `pane-container.js` / `pane-container.css` — multi-pane layout with resizable splits. Used by editor surface. - **Admin surface.** Full-page admin at `/admin/:section` replacing the old modal. Category tabs (People, AI, Routing, System, Monitoring), left nav, section-specific scaffolding via `admin-scaffold.js`. - **Settings surface.** Full-page settings at `/settings/:section` replacing the old modal. Section-specific templates with server-rendered content areas. - **Editor surface rebuild.** File tree pane + code editor pane + chat assist pane. Surface manifest declares three panes with resizable splits. - **`base.html` template shell.** Banner → `#surface` → banner layout. Early theme flash prevention. Universal init block (`Theme.init()` + `UI.restoreAppearance()`). Common scripts: `app-state.js`, `api.js`, `events.js`, `ui-primitives.js`, `ui-core.js`. - **Persona tool grants.** `renderPersonaForm()` gains tool grants section — "All tools" checkbox with categorized tool checklist. `loadToolList()` fetches from `/api/v1/tools`. `loadToolGrants()` reads persona-specific grants. - **`window.__PAGE_DATA__`** injection from surface data loaders. `window.__USER__` from authenticated user context. ### Changed - **Surface navigation.** `UI.openSettings()` and `UI.openAdmin()` navigate to full-page surfaces instead of opening modals. - **CSS decomposition.** Monolithic `styles.css` split into: `variables.css`, `layout.css`, `primitives.css`, `modals.css`, `chat.css`, `panels.css`, `surfaces.css`, `splash.css`, `pane-container.css`, `chat-pane.css`, `user-menu.css`, `tool-grants.css`, `admin-surfaces.css`. ### Migration Notes - **Migration 022:** `surface_registry` table (UUID PK, unique surface_id, is_enabled, is_core, created_at). ## [0.24.3] — 2026-03-07 ### Added - **Anonymous session participants.** Unauthenticated visitors can interact with workflow channels via ephemeral session identities. `session_participants` table stores channel-scoped session tokens with auto-generated display names ("Visitor #N"). Two entry paths: cookie-based (default, random token in `sb_session` cookie, 30-day expiry) and mTLS-based (cert fingerprint as stable session identity). - **`AuthOrSession` middleware.** Tries JWT auth first (Authorization header + `sb_token` cookie), falls back to session cookie for workflow channels. Validates channel is `type=workflow` with `allow_anonymous=true` before creating a session. Sets `auth_type=session` in context with `session_id` and `channel_id`. - **Session-scoped API routes.** `POST /api/v1/w/:id/completions`, `POST /api/v1/w/:id/messages`, `GET /api/v1/w/:id/messages` — all under `AuthOrSession`. Session participants can send messages and trigger completions within their bound channel only. - **Workflow entry page.** `GET /w/:id` renders a standalone chat UI via Go template (`workflow.html`). Minimal layout: channel title/description header, message list, input box, session identity footer. No sidebar, no settings, no navigation — purpose-built for anonymous intake. - **`SessionStore` interface.** `Create`, `GetByToken`, `GetByID`, `ListForChannel`, `CountForChannel`, `Delete`. Both Postgres and SQLite implementations. - **`SessionParticipant` model.** `ID`, `SessionToken`, `ChannelID`, `DisplayName`, `Fingerprint`, `CreatedAt`. Added as channel participant with `participant_type=session`, `role=visitor`. - **`channels.allow_anonymous` flag.** Boolean column (default false) gating session access. Middleware enforces: only `type=workflow` channels with `allow_anonymous=true` accept session auth. - **Session-aware handlers.** `CreateMessage`, `ListMessages`, and `Complete` all check `isSessionAuth()` and enforce channel scope via `sessionCanAccessChannel()`. Session participants bypass user-level budget and model allowlist checks (they use the workflow channel's allocated resources). `Complete` falls back to URL param for `channel_id` on workflow API routes. - **Handler helpers.** `isSessionAuth(c)` and `sessionCanAccessChannel(c, channelID)` in `handlers/channels.go` for consistent session identity checks. ### Migration Notes - **Migration 021:** Creates `session_participants` table (UUID PK, unique session_token, FK to channels, display_name, fingerprint). Adds `allow_anonymous` boolean column to `channels`. Both Postgres and SQLite. - **No new env vars.** mTLS session path reuses existing `AUTH_MODE` and `X-SSL-Client-Fingerprint` header. ### What Doesn't Ship - Workflow definitions and stage transitions (v0.25.0) - Session-to-user promotion ("create an account from your session") - Session expiry and cleanup (future housekeeping job) ## [0.24.2] — 2026-03-07 ### Added - **Fine-grained permission system.** 12 permission constants using `domain.action` convention (`model.use`, `model.select_any`, `kb.read`, `kb.write`, `kb.create`, `channel.create`, `channel.invite`, `persona.create`, `persona.manage`, `workflow.create`, `admin.view`, `token.unlimited`). `AllPermissions` slice for UI rendering and validation. - **Permission resolution.** `auth.ResolvePermissions()` computes effective permissions as the union of the Everyone group plus all explicitly assigned group memberships. Admin users bypass checks entirely. - **`RequirePermission()` middleware.** Gin middleware with per-request permission caching — computed at most once regardless of how many permission gates are chained. `GetResolvedPermissions()` helper for conditional logic in handlers. - **Permission-gated routes.** `POST /personas` requires `persona.create`. `PUT/DELETE /personas/:id` requires `persona.manage`. `POST /knowledge-bases` requires `kb.create`. `POST /knowledge-bases/:id/documents` requires `kb.write`. `POST /channels` requires `channel.create`. `POST /channels/:id/participants` requires `channel.invite`. - **Token budgets.** Per-group daily and monthly token ceilings stored as BIGINT columns on groups. `auth.ResolveTokenBudget()` returns the most restrictive budget across all memberships. Pre-flight enforcement in the completion handler — queries `usage_log` for current period totals, returns 429 when exceeded. BYOK bypass: personal-scope providers skip budget checks entirely. - **Model access control.** Per-group `allowed_models` JSONB column (NULL = unrestricted). `auth.ResolveModelAllowlist()` unions all group allowlists — any group with NULL means unrestricted. Enforced in both the completion handler (403 on disallowed model) and the model list endpoint (filters response so users only see permitted models). - **Everyone group.** Implicit group with stable well-known ID (`00000000-...0001`), `source=system`, seeded in migration 020. All authenticated users receive its permissions without explicit membership. Editable in admin — replaces the previously planned `DefaultUserPerms` / `global_config` approach. `source=system` guard prevents deletion (returns 400 from the store layer). - **Admin permissions endpoints.** `GET /api/v1/admin/permissions` returns all valid permission strings. `GET /api/v1/admin/users/:id/permissions` returns effective resolved permissions for a user. - **Admin group permissions UI.** Group detail view expanded with three new sections: permission checklist (checkbox per permission with description), token budget fields (daily/monthly, empty = unlimited), and model allowlist multi-select (deduped by model_id, all-checked = unrestricted). Single save button sends all fields in one PUT. - **API methods.** `adminListPermissions()`, `adminGetUserPermissions(userId)`. ### Fixed - **OIDC group creation compile error.** `auth/oidc.go` line 412 passed `string` to `*string` field (`Group.CreatedBy`). Fixed to `&createdBy`. ### Migration Notes - **Migration 020:** Adds `permissions` (JSONB, default `'[]'`), `token_budget_daily` (BIGINT), `token_budget_monthly` (BIGINT), `allowed_models` (JSONB) columns to `groups` table. Extends `groups.source` CHECK to include `system`. Drops `NOT NULL` on `created_by` (NULL for system-seeded groups). Seeds the Everyone group with `model.use`, `kb.read`, `channel.create` permissions. - Both Postgres and SQLite migrations included. - **No new env vars.** ## [0.24.1] — 2026-03-07 ### Added - **mTLS auth provider.** `server/auth/mtls.go` — authenticates via reverse proxy headers (`X-SSL-Client-DN`, `X-SSL-Client-Verify`, `X-SSL-Client-Fingerprint`). Parses RFC 2253 distinguished names, auto-provisions users from cert CN, uses fingerprint as stable external identity. Configurable via `MTLS_HEADER_DN`, `MTLS_HEADER_VERIFY`, `MTLS_DEFAULT_ROLE` env vars. - **OIDC auth provider.** `server/auth/oidc.go` — OpenID Connect with authorization code flow. JWKS key caching with auto-refresh on rotation, RSA token validation, split-horizon issuer support (`OIDC_ISSUER_URL` for backend, `OIDC_EXTERNAL_ISSUER_URL` for browser). Claim extraction for `preferred_username`, `email`, `name`, `groups`, `realm_access.roles`. Role mapping via configurable `OIDC_ADMIN_ROLE`. Group sync: adds/removes OIDC-sourced group memberships on every login based on IdP groups claim. - **OIDC login flow.** `GET /api/v1/auth/oidc/login` redirects to IdP. `GET /api/v1/auth/oidc/callback` exchanges code for tokens, validates, auto-provisions user, issues internal JWT, redirects to login page with base64-encoded token fragment. Login page JS picks up `#oidc_result=...`, saves to localStorage, redirects to app. - **Login page SSO button.** Template adapts by `AUTH_MODE`: builtin shows username/password form, OIDC shows "Sign in with SSO" button, mTLS shows certificate prompt. - **Keycloak integration test environment.** `docker-compose-keycloak.yml` extends base compose with Keycloak 26 + pre-configured realm. `ci/keycloak-realm.json` includes switchboard client, group mapper, two test users (alice/user, bob/admin), two groups (engineering, leads). One command: `docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up`. - **`QArgs()` dialect adapter.** Handles Postgres `$N` placeholder reuse for SQLite — expands args to match positional `?` placeholders. Wrapper functions `database.QueryRow()`, `database.Query()`, `database.Exec()` (+ Context variants) replace `database.DB.Query(database.Q(...))` pattern with automatic arg expansion. - **`QArgs` unit tests.** 5 tests covering reused placeholders, no-reuse passthrough, ILIKE rewrite, high placeholder numbers, Postgres no-op. - **Channel list regression test.** `TestIntegration_ChannelListWithTypeFilter` exercises `types=direct,group` query path that previously returned 500 on SQLite. ### Fixed - **SQLite `_time_format` DSN parameter.** Removed `_time_format=2006-01-02T15:04:05Z` from `modernc.org/sqlite` connection string — this parameter is `mattn/go-sqlite3`-only and caused `database ping: unknown _time_format` on startup. - **SQLite store wiring.** `main.go` store initialization now uses `sqliteStore.NewStores()` when `DB_DRIVER=sqlite` instead of always using `postgres.NewStores()`. Fixes nil pointer panic in health accumulator goroutine. - **nginx resolver for localhost.** Changed `set $backend http://localhost:8080` to `http://127.0.0.1:8080` — IP literal doesn't require DNS resolution inside container. - **nginx extension asset routing.** Changed `location /api/` to `location ^~ /api/` — prevents `location ~* \.(js)$` regex from intercepting `/api/v1/extensions/.../main.js` requests. - **Channel list 500 on SQLite.** Count query uses `user_id = $1 OR ... participant_id = $1` — Postgres allows reusing `$1` but SQLite's `?` is positional. Fixed with `QArgs` arg expansion. - **OIDC group sync FK constraint.** `findOrCreateOIDCGroup` now receives the provisioning user's ID as `created_by`, satisfying the `groups.created_by REFERENCES users(id)` foreign key. ### Migration Notes - **Migration 019:** `oidc_auth_state` table (ephemeral OIDC flow state), `groups.source` column (`manual`/`oidc`). - **New env vars:** 10 mTLS (`MTLS_*`) + 11 OIDC (`OIDC_*`) + `AUTH_MODE` extended to accept `mtls`/`oidc`. ## [0.24.0] — 2026-03-06 ### Added - **Auth provider abstraction.** New `server/auth/` package with `Provider` interface: `Authenticate()`, `Register()`, `SupportsRegistration()`, `Mode()`. Three modes defined: `builtin` (implemented), `mtls` (v0.24.1), `oidc` (v0.24.1). `AUTH_MODE` env var selects provider at startup. mTLS/OIDC fail-fast with clear error if selected before implementation lands. - **Builtin provider.** `auth.BuiltinProvider` extracts login/register logic from `handlers/auth.go` into the `Provider` interface. Identical behavior to v0.23.2 — reads `{login, password}` from request body, validates bcrypt hash, returns user with plaintext password as `VaultHint` for UEK unlock. - **User handles.** `handle` column on `users` table — unique, auto-generated from username via `models.HandleFromName()`. Used as the `@mention` identifier for human users. Editable. `UniqueHandle()` appends `-2`, `-3` suffixes on collision (same pattern as persona handles). - **Auth source tracking.** `auth_source` column on `users` table (`builtin`/`mtls`/`oidc`). `external_id` column for IdP subject IDs or cert fingerprints. Composite unique index on `(auth_source, external_id)`. - **`GetByHandle()`** and **`GetByExternalID()`** on `UserStore` interface — both Postgres and SQLite implementations. - **Design document.** `docs/DESIGN-0.24.0.md` — full spec for v0.24.0–v0.24.3 (auth abstraction, mTLS/OIDC, permissions, anonymous sessions). All open questions resolved. ### Changed - **Auth handler refactored.** `AuthHandler` now holds a `provider auth.Provider` field. `Login()` delegates to `provider.Authenticate()`, then handles vault unlock and JWT generation. `Register()` delegates to `provider.Register()`. `generateTokens()` response includes `handle` and `auth_source`. - **`BootstrapAdmin()` / `SeedUsers()`** backfill `handle` for existing users on startup (idempotent — skipped if handle already set). New users created with `auth_source=builtin` and auto-generated handle. - **@mention resolution uses handles.** `resolveMention()` steps 3–4 now query `LOWER(handle)` instead of `LOWER(username)`. Backend and frontend aligned. - **User search includes handles.** `GET /api/v1/users/search` returns `handle` field and filters on handle alongside username and display_name. - **Autocomplete matches on handle.** `channel-models.js` filters user candidates by handle, username, and display name. The `@mention` token inserted into the input is the user's handle (not username). - **User store queries updated.** All `SELECT` statements in both Postgres and SQLite user stores include `auth_source`, `external_id`, `handle`. Unified `scanOneUser` helper in Postgres, `scanOne` in SQLite — eliminates per-method scan block duplication. - **User list API.** `List()` response includes `auth_source` and `handle` for all users. ### Migration Notes - **DB wipe required.** Migration 018 adds three columns to `users`. Existing users backfilled with `auth_source='builtin'` and handle derived from username. - **New columns:** `users.auth_source`, `users.external_id`, `users.handle`. - **New indexes:** `idx_users_handle` (unique), `idx_users_external_id` (unique composite, partial). ## [0.23.2] — 2026-03-06 ### Added - **Unified active conversation.** `App.activeConversation = { id, type }` replaces `App.currentChatId` and `App.currentChannelId`. All code paths (send, stream, model bar, session restore, sidebar highlight) keyed off `App.activeId`. `setActive(id, type)` method, `activeType` getter, `getActiveChat()` helper. - **Group leader default response.** When a `type=group` channel receives a message without an @mention, the leader persona responds. Leader resolved from `persona_group_members WHERE is_leader` via channel participants. - **`@all` fan-out.** `@all` in message content routes to every persona participant. Depth-1 only — responses from @all do not trigger further chains. - **Human message attribution.** Messages from other human participants show their avatar and display name instead of "You". Persona attribution verified for loaded history in channel context. - **Channel lifecycle.** Archive action via context menu (sets `is_archived`, `archived_at`). Delete gated by `channel_retention.mode` policy (`flexible` allows delete, `retain` forces archive-only). Admin purge with `purge_after_days` floor. Retention config keys in `global_config`. - **Participant mutation guards.** DMs block removal below 2 human participants. Groups block removal of last persona (returns 400 with clear error). - **Channel context banner.** Slim bar below chat header showing ai_mode, topic, and routing hints. Adapts by conversation type (DM: partner name + @mention hint; Group: leader/all routing; Channel: ai_mode + topic). - **`NotifyUserMention` via WebSocket.** Replaced broken gin context interface cast with direct `hub.SendToUser()`. Delivers `user.mentioned` event with channel_id, from_user, content preview. - **User mention notification toast.** `user.mentioned` WebSocket event handler shows toast with content preview and increments unread badge. ### Fixed - **Channel persistence through refresh.** `loadChannels()` read `resp.channels` but `ListChannels` returns paginated response with `data` key. Channels vanished on reload. - **Folder drag-and-drop.** Three compounding issues: `folderId` never mapped in `loadChats()`, folder groups had no drag handlers, no unfiled drop zone. All fixed. - **DM creation 404.** Route didn't exist. Added `GET /api/v1/users/search?q=` endpoint. Rewrote DM creation UI from text input to lazy search modal. - **Channel ⋯ menu inconsistency.** Replaced ✕ delete button with ⋯ hover button matching folder pattern. - **Folder ⋯ button reflow.** `display:none/block` caused arrow shift on hover. Changed to `visibility:hidden/visible`. - **Folder delete spill.** Now shows three-button modal: "Keep chats" / "Delete chats too" / Cancel. - **Unread subquery dependency.** Query used `last_read_message_id` (migration 017) which may not exist. Rewrote to use `last_read_at` (migration 005). - **Settings Models section.** Template section fell through to generic div. Added dedicated template section. - **`u.avatar` → `u.avatar_url`** in `ListMessages` JOIN and `treepath/path.go` `resolveSenderInfo()`. ### Migration Notes - **Migration 017:** `last_read_message_id` column on `channel_participants`. ## [0.23.1] — 2026-03-05 ### Added - **Conversation type taxonomy.** Five types: `direct` (1:1 AI chat), `dm` (human-to-human), `group` (multi-participant), `channel` (named persistent space), `workflow` (staged process, deferred). Channel type constraint extended. - **`ai_mode` on channels.** `auto` (AI responds to every message), `mention_only` (AI silent unless @mentioned), `off` (AI disabled). Completion handler checks `ai_mode` before dispatching. - **Three-section sidebar.** Projects → Channels → Chats. Each section independently collapsible with localStorage persistence. Channels sorted by last activity. Chats keep existing time-grouped behavior. - **Folder system.** `chat_folders` table with full CRUD. Drag chats into/out of folders. Folder context menu: rename, delete. `folder_id` column on channels. - **DM plumbing.** `resolveMention()` extended to resolve users (exact + prefix on username, self-mention blocked). User @mention skips AI, delivers notification. DM creation flow with user search. - **Presence heartbeat.** `user_presence` table. `POST /presence/heartbeat` (30s upsert), `GET /presence?users=...` (90s threshold). Client heartbeat interval with visibility pause/resume. Presence WebSocket events. - **Channel participant CRUD.** `channel_participants` handler: list, add, update role, remove. Auto-created on channel creation. DM participants auto-added from `req.Participants`. - **Persona groups wired.** CRUD endpoints for `persona_groups` and `persona_group_members`. `is_leader` flag on members. - **`topic` column** on channels for header display. - **User search endpoint.** `GET /api/v1/users/search?q=` — returns id, username, display_name for active users (max 20, excludes caller). ### Changed - **`CreateChannel` accepts `type` field** (default `direct`). `ListChannels` supports `?types=dm,channel` multi-filter. - **Channel sidebar rendering.** `renderChannelsSection()` shows # for channels, person icon for DMs. Online dots from presence data. Unread badges. - **Chat list filtering.** `renderChatList` excludes `type=channel` and `type=dm` — only direct/group chats appear in the Chats section. ### Migration Notes - **Migration 016:** `ai_mode`, `topic` on channels. `user_presence` table. Channel type constraint extended to include `dm`, `channel`. ## [0.23.0] — 2026-03-05 ### Added - **@mention routing.** Type `@persona-handle` or `@model-id` in any chat to route the completion to a specific persona or model. Works in all chats — no roster or group setup required. Resolution order: persona handle (exact, then prefix) → model catalog (exact, then prefix). Backend `resolveMention()` does direct DB lookup against `personas.handle` and `model_catalog.model_id`. - **Persona handles.** Every persona gets a unique `handle` field (e.g. `veronica-sharpe`) auto-generated from the name. Editable in the persona form. Stored in `personas.handle` with a unique index. Used as the @mention identifier — no spaces, no ambiguity. - **@mention autocomplete.** Typing `@` in the chat input shows a filterable popup of all enabled models and personas with avatars, display names, `@handle` hints, and provider info. Works in any chat. Matches against handle, model ID, and display name. Arrow keys + Enter/Tab to select, Escape to dismiss. - **@mention pill rendering.** @mentions in message content render as styled accent-colored pills. Handles and display names both highlighted. Skipped inside `` and `
` blocks.
- **AI-to-AI chaining.** When a persona response contains an @mention of another persona, a follow-up completion fires automatically. Delivered via WebSocket (`message.created` event). Chain depth capped at 5. Self-mention blocked. Uses the same `resolveMention()` as user @mentions.
- **Participant list injection.** System prompt includes a list of available @mentionable personas so LLMs know who they can invoke. Injected in `loadConversation()` after memory hints. Excludes the current persona (no self-mention). Empty when no other personas exist (zero overhead on 1:1 chats).
- **Context boundaries.** When switching personas via @mention, a system message is injected before the user's message telling the target persona to ignore previous personas' styles. When a non-persona model responds after persona messages exist in history, a boundary tells it to use its own natural style.
- **Provider proxy support.** `provider_configs` table gains `proxy_mode` (system/direct/custom) and `proxy_url` columns. Provider HTTP clients use `proxy_mode` to configure transport: `system` = env-based (`HTTP_PROXY`), `direct` = no proxy, `custom` = explicit URL. All four providers (Anthropic, OpenAI, OpenRouter, Venice) updated.
- **Persona groups schema.** `persona_groups` and `persona_group_members` tables added (migration 004). Structural foundation for saved roster templates — CRUD and FE not yet implemented.
- **Per-provider model preferences.** `user_model_settings` unique key widened to `(user_id, model_id, provider_config_id)`. Same model from different providers gets independent visibility toggles. Frontend uses composite keys throughout.
- **Channel participants.** `channel_participants` table with polymorphic `participant_type` (user/persona/session), `role` (owner/member/observer). CRUD endpoints: list, add, update role, remove. Auto-created on channel creation.
- **Persona avatar resolution.** Assistant messages resolve persona portraits through channel roster → participant data → App.models lookup. Streaming messages also resolve avatars.
- **Save message to note.** Per-message "Note" button (visible on hover) opens a modal to create a new note or append to an existing note from any message. Supports text selection — selected text is saved instead of full message.
- **Group chat creation.** "Group Chat" option in New Chat dropdown. Persona picker with handles, scope badges, and model info. Creates channel + adds persona participants.
- **Chat type indicators.** Sidebar shows 👥 for group chats, ⚙ for workflow channels.

### Changed
- **Channel models constraint.** `channel_models` unique constraint split into two partial indexes: raw models keyed on `(channel_id, model_id, provider_config_id) WHERE persona_id IS NULL`, persona entries keyed on `(channel_id, persona_id) WHERE persona_id IS NOT NULL`. Two personas on the same underlying model now get separate roster entries.
- **Channel models queries.** `GetModels` and `GetModelByID` in both Postgres and SQLite stores now JOIN `personas` table to carry handle data alongside display name and persona ID.
- **User message alignment.** User bubbles use `flex: initial` with `max-width: 85%` to shrink-wrap content and right-align within the centered 768px column.
- **Channel list loading.** `loadChats()` no longer filters by `type=direct` — all channel types (direct, group, workflow) load on refresh.
- **Autocomplete CSS.** Replaced legacy CSS variable names (`--bg-primary`, `--text-secondary`, etc.) with current theme variables (`--bg-surface`, `--bg-raised`, `--text-3`, etc.) across all `channel-models.css`.
- **Persona form.** Added @mention handle field with auto-generation from name, monospace styling, and edit-locks (manual edit stops auto-gen). Handle included in `getValues()`, `setValues()`, and `clearForm()`.
- **Model dropdown.** Persona entries show `@handle` hint next to display name in accent color monospace.
- **Completion chain.** Rewritten to use `resolveMention()` directly instead of roster-based mention parsing. No roster, participant list, or channel_models dependency. Same function for user→LLM and LLM→LLM routing.

### Fixed
- **Notes modal visibility.** `_showSaveToNoteModal` now creates overlay with `modal-overlay active` class (was missing `active`, modal was invisible).
- **Single @mention routing.** Single-target @mentions no longer reload conversation (which caused duplicate user messages that broke Anthropic's API). System prompt swapped in-place on existing messages array.

### Migration Notes
- **DB wipe required.** Migrations 003, 004, and 005 modified. No upgrade path from previous schema — drop and recreate dev DB.
- **New columns:** `personas.handle`, `provider_configs.proxy_mode`, `provider_configs.proxy_url`.
- **New tables:** `persona_groups`, `persona_group_members`, `channel_participants`.
- **New indexes:** `idx_personas_handle` (unique), `idx_channel_models_raw` (partial), `idx_channel_models_persona` (partial).

## [0.22.8] — 2026-03-04

### Changed
- **Naming cleanup: preset → persona.** Unified domain terminology across the entire codebase. Routes `/presets` → `/personas` (user, team, admin). JSON response keys, request fields, Go struct fields, handler names, JS functions, CSS classes, DOM IDs, template strings, display text — all consistently use "persona". File renamed: `presets.go` → `personas.go`. Dual JSON keys (`"personas"` + `"presets"`) collapsed to single `"personas"` key. Config key `user_presets` → `user_personas`. Banner color presets intentionally left as "presets" (different domain concept).
- **Naming cleanup: APIConfigID → ProviderConfigID.** All Go structs and JSON fields now use `provider_config_id` consistently.
- **Removed aliases.** `chat_id` field removed from completion handler. `/models` route alias removed (use `/models/enabled`).
- **Shared app-state.js.** Extracted `App` state object, `fetchModels()`, and `resolveCapabilities()` from chat-only `app.js` into new `app-state.js` loaded by `base.html` for all surfaces. Settings and admin surfaces now have access to `App` state for model dropdowns, settings, and user context.
- **Auth tokens loaded for all surfaces.** `API.loadTokens()` now runs in `base.html` inline script so settings, admin, editor, and notes surfaces can make authenticated API calls.
- **Script loading consolidated in base.html.** `ui-core.js` moved from per-surface loads to `base.html`. `ui-primitives-additions.js` (never loaded by any surface) now loaded in `base.html`. All surfaces get the complete shared stack: app-state → api → events → ui-primitives → ui-primitives-additions → ui-core → pages.

### Added
- **Settings surface functions.** `UI.loadGeneralSettings()` populates the general settings form (model dropdown, system prompt, temperature, max tokens, thinking toggle) with server data. `UI.saveAppearance()` persists theme, scale, and font size. `UI.loadTeamsSettings()` renders team membership on the settings teams tab.
- **Design documentation.** `docs/DESIGN-SURFACES.md` (four-layer surface architecture, extension hooks, trust model), `docs/ICD-API.md` (domain-organized API contract, 240 routes), `docs/ICD-AUDIT.md` (cross-reference audit with resolution status).

## [0.22.7] — 2026-03-03

### Added
- **Theme system.** New `theme.css` provides complete light/dark theming via CSS custom properties with `[data-theme]` attribute switching. Variables cover backgrounds, surfaces, text, accents, borders, shadows, and component-specific tokens. System preference auto-detection via `prefers-color-scheme` media query. Old variables (`--bg-primary`, `--text-primary`) bridged via fallback chains (`var(--bg, var(--bg-primary, #0e0e10))`) — zero breakage of existing CSS.
- **ChatPane component.** New `chat-pane.js` provides a mountable, self-contained chat pane factory replacing the hardcoded singleton pattern. `ChatPane.create(opts)` returns an instance with `renderMessages()`, `appendChunk()`, `finalizeStream()`, `appendTyping()`, `removeTyping()`, `scrollToBottom()`, `showWelcome()`, `clear()`, `getInputValue()`, `setInputValue()`, `focusInput()`, `destroy()`. Lookup via `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`. Each instance owns its own DOM elements and channel binding.
- **Chat pane template component.** New `components/chat-pane.html` Go template partial: `{{template "chat-pane" dict "ID" "main"}}` renders a complete chat scaffold with messages container, input bar, model selector, send button, and toolbar mount points. Used by editor surface assist pane.
- **Splash/login surface.** Login page rewritten from minimal form to split-panel splash layout: animated grid canvas on hero side, tabbed auth card (Login + Register) on right. Registration form includes avatar upload, display name, email. Powered by new `pages-splash.js` module with `Pages.initSplash()`, grid animation, and `POST /api/v1/auth/register` + `PUT /api/v1/users/me/avatar` integration.
- **UI primitives additions.** New `ui-primitives-additions.js` extends the shared primitive library: `Toast` (show/success/error/warning/info with auto-dismiss stacking), `renderBadge()` (6 color variants), `renderIcon()` (20+ SVG icon set), `renderIconBtn()` (icon button factory with active state), `Theme` (init/set/get/resolved/renderToggle with localStorage persistence), `showConfirmDialog()` (enhanced with variant/callbacks), `mountAvatarUpload()` (file input with preview).
- **Settings BYOK gate.** Settings surface nav now conditionally shows "My Providers", "Model Roles", and "Usage" tabs only when BYOK is enabled. Server-side gate via `{{if .Data.BYOKEnabled}}` reads `GlobalConfig` key `user_providers.enabled`. Includes BYOK status indicator in nav footer with UEK encryption notice.
- **Settings User Personas gate.** Settings surface conditionally shows "My Personas" tab when user-created personas are enabled. Server-side gate via `{{if .Data.UserPersonasEnabled}}` reads `GlobalConfig` key `user_presets.enabled`.
- **Settings Models tab.** New "Models" nav link in settings surface for user model visibility preferences.
- **Editor assist pane.** Editor surface now includes a `chat-pane` template component for the right-side assist pane with split handle, wired up via `ChatPane.create()` on DOM ready.

### Changed
- **`base.html` template.** Added `data-theme` attribute on `` element for theme system. Google Fonts preconnect + DM Sans / JetBrains Mono stylesheet. `theme.css` loaded before `styles.css`. New shared script tags for `ui-primitives-additions.js` and `chat-pane.js` (available on all surfaces). Added `window.__SETTINGS__` global from `PageData.SurfaceSettings`.
- **`PageData` struct** (`pages.go`). New fields: `Theme` (string), `SurfaceSettings` (any, serialized to `__SETTINGS__`), `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`. `Render()` defaults `Theme` to `"dark"` when unset.
- **`RenderLogin()`** (`pages.go`). Now calls `loadBranding()` and `isRegistrationOpen()` to populate splash/login template fields from `GlobalConfig` keys `branding.*` and `registration.enabled`.
- **`SettingsPageData`** (`loaders.go`). Added `BYOKEnabled` and `UserPersonasEnabled` boolean fields. `settingsLoader()` reads from `GlobalConfig` keys `user_providers.enabled` and `user_presets.enabled`.
- **`settings.html` template.** Reorganized nav: base tabs (General, Appearance, Models, My Presets) always visible; BYOK section (My Providers, Model Roles, Usage) gated; User Personas gated; then Knowledge, Memory, Notifications, Teams. Added `my-personas` to dynamic section loaders.
- **`editor.html` template.** Added `chat-pane` component in right-side assist pane with split handle and `ChatPane.create()` initialization.

### New Files
- `src/css/theme.css` — 305 lines: CSS custom properties, shared components, surface layouts
- `src/js/chat-pane.js` — 162 lines: mountable chat pane factory
- `src/js/ui-primitives-additions.js` — 199 lines: Toast, Badge, Icons, Theme, confirm, avatar
- `src/js/pages-splash.js` — 154 lines: splash init, login, register, grid canvas
- `server/pages/templates/components/chat-pane.html` — 23 lines: reusable template partial

## [0.22.6] — 2026-03-03

### Fixed
- **Split FE/BE deployment: page route proxying.** Frontend container now supports `BACKEND_URL` env var. When set, the entrypoint generates nginx `proxy_pass` blocks for page routes (`/`, `/login`, `/chat/:id`, `/admin/*`, `/editor/*`, `/notes/*`, `/settings/*`) so Go template rendering works in k8s split-image deployments. When unset, behavior is unchanged (SPA fallback). Both `BASE_PATH=""` and `BASE_PATH="/prefix"` variants supported.
- **CI test timeout.** All `go test` commands now include `-timeout 8m` to prevent indefinite hangs from runner resource contention or race-detector compilation stalls. Previously used Go's 10-minute default with no goroutine dump on timeout.

### Removed
- **`router.js` (322 lines).** Client-side hash router fully replaced by server-side page routes. All `Router.*` call sites in `chat.js` replaced with `history.replaceState()` for URL updates. All `typeof Router` guards in `app.js` removed.
- **`surfaces.js` (368 lines).** Client-side surface registry replaced by Go template surface architecture. Extension API stubs (`surfaces.register`, `surfaces.activate`, etc.) now log deprecation warnings. `surfaces.getCurrent()` returns `window.__SURFACE__` from server-rendered page data.
- **`index.html` SPA shell (1,296 lines → 16 lines).** Original monolithic SPA entry point replaced with a minimal redirect page. All real routes now served by Go templates; `index.html` only serves as nginx `try_files` fallback for unknown paths.
- **`editor-mode.js` old Surfaces paths (~260 lines).** Removed `_register()`, `openDirect()`, `_unregister()`, `_activate()`, `_deactivate()`, `_embedChat()`, `_returnChat()`, and full `check()`/`checkStartup()` implementations. Only `mountServerRendered()` remains as the entry point. `check()` and `checkStartup()` kept as no-ops for backward compat with `projects-ui.js` callers.
- **SPA-only entrypoint code path.** `docker-entrypoint-fe.sh` now requires `BACKEND_URL` (fails fast if unset). Removed dead `%%BASE_HREF%%`, `%%ENVIRONMENT%%`, `%%BRANDING_JSON%%` injection into index.html. Removed branding config file loading (branding now handled by Go templates).

### Changed
- `docker-entrypoint-fe.sh`: Simplified from 227 → 184 lines. Requires `BACKEND_URL`. Removed SPA-only fallback path.
- `nginx.conf` (unified container): Consolidated repeated `proxy_set_header` blocks. Uses `$backend` variable. Page routes proxy to Go backend for template rendering.
- `base.html`: Added `__ENV__` and `__BRANDING__` globals for JS compatibility (previously injected by index.html sed).
- `server/pages/pages.go`: Added `Environment` field to `PageData`, populated from config.
- `extensions.js`: `ui.replace()`, `ui.restore()`, and `surfaces.*` API methods now log deprecation warnings instead of calling removed Surfaces system.
- `app.js`: Removed `Surfaces.init()`, `EditorMode.checkStartup()`, and `Router.init()` calls. Simplified to direct `sessionStorage` chat restore.
- `chat.js`: Replaced `Router.update()` calls with `history.replaceState()` for URL-bar sync on chat selection/creation.
- `repl.js`: Removed `surfaces.js` import reference.

## [0.22.5] — 2026-03-03

### Added
- **Go template engine.** Server-side HTML rendering via `html/template` with `//go:embed` for compiled-in templates. Custom `FuncMap` with helpers (`roleFilterType`, `toJSON`, `hasPrefix`, `dict`). CSP nonce generation per request. Replaces monolithic `index.html` + client-side DOM construction with composable, server-rendered surfaces.
- **Surface architecture.** Each page route renders a dedicated surface template that owns its full layout below the classification banner. Surfaces compose from reusable components (`model-select`, `team-select`, `file-upload`) without sharing CSS layout rules. Banner height handled via CSS custom properties (`--banner-top-h`, `--banner-bot-h`, `--surface-h`).
- **Chat surface.** Server-rendered shell at `/` and `/chat/:chatID`. Go template renders banner + layout + script tags; existing JS builds DOM inside the container. Bridge pattern preserves all current chat functionality.
- **Admin surface.** Full admin panel at `/admin/:section` with 5 category tabs (People, AI, Routing, System, Monitoring) and section sidebar. Server-rendered pages for providers, models, teams, users, and settings. Hybrid fallback for JS-loaded sections (groups, presets, knowledge, memory, health, capabilities, extensions, storage, usage, audit, stats).
- **Editor surface.** Server-rendered layout shell at `/editor/:wsId` with proper CSS height ownership. `mountServerRendered()` method on `EditorMode` bypasses the Surfaces registry for Go template mode. Dedicated CSS owns full viewport below banners — no layout collision with other surfaces. **(Fixes bugs #4, #5: editor layout collapse)**
- **Notes surface.** Server-rendered layout at `/notes/:noteId` with notes-main + notes-assist split. Boot script calls `openNotes()` in standalone mode. Responsive: assist pane hidden on mobile.
- **Settings surface.** Full-page settings at `/settings/:section` replacing modal-based settings. Left nav with all sections (general, appearance, providers, models, presets, roles, knowledge, memory, notifications, usage). General and Appearance sections server-rendered with form controls; dynamic sections populated by existing JS.
- **Login page.** Standalone Go template at `/login` with cookie-based auth. Sets `redirect_after_login` cookie for post-login navigation.
- **`AuthOrRedirect` middleware.** Cookie-based JWT validation for page routes. Reads `sb_token` cookie, redirects to `/login` on missing/invalid token (instead of 401 JSON). `RequireAdminPage()` helper aborts with 403 for non-admin users. Skips auth entirely in unmanaged mode.
- **Cookie-based auth sync.** `api.js` `saveTokens()` now sets `sb_token` cookie alongside localStorage on every token save/refresh. `clearTokens()` clears the cookie. Bridges API auth (Bearer header) with page auth (cookie).
- **Reusable `model-select` component.** Go template partial renders `