Changeset 0.22.7 (#149)

This commit is contained in:
2026-03-04 10:44:42 +00:00
parent d8e0664fa3
commit 389e47b0f9
62 changed files with 6820 additions and 1476 deletions

746
docs/DESIGN-SURFACES.md Normal file
View File

@@ -0,0 +1,746 @@
# DESIGN — Surface & Extension Architecture
**Status:** Accepted
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane)
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
---
## Overview
The UI is a four-layer architecture: Primitives, Components, Surfaces,
and Banners. Each layer has a single responsibility and a strict
dependency direction — primitives know nothing, components compose
primitives, surfaces compose components, banners are platform chrome
outside the surface boundary.
Extensions participate at every layer through six defined hooks.
Admin-installed extensions are privileged (full DOM, direct API access,
own routes). Themes are pure CSS custom property overrides that
propagate through every layer uniformly.
This document defines the layer contracts, extension hook system,
display content model, trust boundaries, and theme architecture.
---
## Definitions
A **surface** is a full-page layout — a composition of components with
a route, a data loader, and a boot script. Chat is a surface. The
editor is a surface. A custom triage intake form is a surface.
An **extension** is a package — a manifest plus code plus assets that
participates in the platform through hooks. An extension can do one,
some, or all of:
- Register tools the LLM can call (Hook 5: Tool Bridge)
- Transform content during streaming (Hook 2: Stream Processing)
- Enhance rendered messages (Hook 3: Post-Render)
- Attach visual content to messages (Hook 4: Display Content)
- Inject panels or sections into existing surfaces (Hook 6: Surface Injection)
- Create entirely new surfaces with their own routes (Surface Registration)
The five **core surfaces** (Chat, Editor, Notes, Admin, Settings) exist
without extensions. They are built from the same primitive and component
layers that extensions use, but they are registered directly in Go
rather than through a manifest.
An extension that creates a surface gets its own route (`/s/:slug`),
its own data loader, and the full primitive/component library. An
extension that only registers a post-render hook (like a block
renderer) doesn't create any surfaces at all.
**Examples:**
| Extension | Creates Surface? | Hooks Used |
|-----------|-----------------|------------|
| Mermaid renderer | No | Post-Render |
| KaTeX renderer | No | Post-Render |
| Calculator tool | No | Tool Bridge |
| Image Generator | Yes (`/s/gallery`) | Tool Bridge, Stream Processing, Post-Render, Display Content, Surface |
| Custom Dashboard | Yes (`/s/dashboard`) | Surface, Surface Injection (admin section) |
---
## Existing Extension Mapping
The current extension mechanisms map directly into this architecture:
| Current Mechanism | Architecture Equivalent |
|-------------------|------------------------|
| Block renderers (`ctx.renderers.register()`) | Hook 3: Post-Render |
| Tool bridge (`ctx.tools.register()`) | Hook 5: Tool Bridge |
| `ctx.ui.toast()`, `ctx.ui.openPreview()` | Primitive layer (unchanged API) |
| `ctx.ui.isDark()`, `ctx.ui.isMobile()` | Theme layer queries |
| `ctx.surfaces.getCurrent()` | Returns `window.__SURFACE__` |
The manifest schema gains optional new fields (`hooks`, `surfaces`,
`surface_injections`, `theme`). Existing fields retain their meaning.
`Extensions.boot()` is the single entry point for extension
initialization on every surface. It loads manifests, registers
renderers, and initializes tool bridges — same sequence, available
on every surface rather than only chat.
---
## Layer Model
```
┌──────────────────────────────────────────────┐
│ Banner (top) │ ← Platform config
├──────────────────────────────────────────────┤
│ │
│ Surface │ ← Layout + lifecycle
│ ┌─────────────┬──────────────────────────┐ │
│ │ Component │ Component │ │ ← Domain-aware
│ │ (ChatPane) │ (NoteEditor) │ │
│ │ ┌─────────┐ │ ┌────────┐ ┌──────────┐ │ │
│ │ │Primitive│ │ │Primitv.│ │Primitive │ │ │ ← Atomic UI
│ │ │ (input) │ │ │ (menu) │ │ (toggle) │ │ │
│ │ └─────────┘ │ └────────┘ └──────────┘ │ │
│ └─────────────┴──────────────────────────┘ │
│ │
├──────────────────────────────────────────────┤
│ Banner (bottom) │ ← Platform config
└──────────────────────────────────────────────┘
```
Four layers, strict dependency direction: Primitives know nothing.
Components use Primitives. Surfaces compose Components. Banners are
global chrome outside the surface boundary.
---
## Layer 1: Primitives
Atomic UI elements. No domain logic. A toggle doesn't know if it's
toggling a KB or a theme — it takes a label, a state, and a callback.
Styled entirely via CSS custom properties (the theme contract). Every
primitive reads from the same property namespace, so theme changes
propagate instantly.
### Catalog
| Primitive | Description |
|-----------|-------------|
| `Input` | Text, textarea, number, password, with label + validation |
| `Select` | Dropdown, single or multi |
| `Toggle` | Boolean switch with label |
| `Checkbox` | Checkbox with label |
| `Button` | Text button, variants: primary, secondary, danger, ghost |
| `IconButton` | Icon-only button with tooltip |
| `ColorPicker` | Color input with hex text field |
| `Menu` | Dropdown or context menu, item list with icons + shortcuts |
| `Dialog` | Modal: confirm, prompt, or custom form content |
| `Toast` | Transient notification: success, error, warning, info |
| `Badge` | Inline label: accent, success, danger, warning, muted |
| `Avatar` | Image circle with upload affordance |
| `Tabs` | Tab bar with content switching |
| `FormGroup` | Label + input + validation message + help text |
| `SectionHeader` | Titled section divider |
| `EmptyState` | Placeholder with icon + message + action |
| `Spinner` | Loading indicator |
| `Table` | Sortable, paginated data table |
### Implementation
Each primitive is a factory function that returns a DOM element (or
attaches to an existing one). No classes, no inheritance — just
functions.
```js
// Example — not prescriptive API, just the pattern:
Primitives.toggle({ label: 'Auto-search', value: true, onChange: fn })
Primitives.menu({ anchor: el, items: [...], onSelect: fn })
Primitives.dialog({ title: 'Confirm', body: el, onConfirm: fn })
```
Current locations: `ui-primitives.js`, `ui-primitives-additions.js`,
and Go template components (`model-select.html`, `team-select.html`,
`file-upload.html`). These converge into one coherent set.
### Go Template Primitives
Some primitives need server-rendered initial state (e.g., model-select
needs the model list, team-select needs the team list). These are Go
template partials that render the HTML + initial data, then JS hydrates
interactivity on load.
```html
{{template "model-select" dict "ID" "channelModel" "Models" .Models "Type" "chat"}}
```
The JS primitive attaches to the server-rendered DOM by ID, not by
replacing it.
---
## Layer 2: Components
Domain-aware compositions of primitives. Each component owns its DOM
subtree, manages its own state, and exposes a clean API for the surface
to interact with.
### Catalog
| Component | Primitives Used | Domain |
|-----------|----------------|--------|
| `ChatPane` | Input, Button, Menu, Spinner, Toast | Channels, Messages, Streaming |
| `NoteEditor` | Input (CM6), Menu, Tabs, Badge | Notes, Wikilinks, Folders |
| `FileTree` | Menu (context), Button, Spinner | Workspaces, Files |
| `ModelSelector` | Select, Badge, Spinner | Models, Capabilities, Health |
| `PersonaPicker` | Select, Badge, Avatar | Personas, Scopes |
| `KBPicker` | Toggle, Badge, Select | Knowledge Bases, Discoverability |
| `ProjectSidebar` | Menu, Tabs, Badge, EmptyState | Projects, Channels, DnD |
| `AuditLog` | Table, Select, Badge | Audit, Filtering, Pagination |
| `CodeEditor` | Input (CM6), Select, Tabs | Workspaces, Languages |
| `SettingsForm` | FormGroup, Toggle, Select, Button | User/Admin Settings |
### Instance Pattern
Components use the factory pattern established by ChatPane:
```js
const pane = ChatPane.create({
messagesEl: document.getElementById('editorChatMessages'),
inputEl: document.getElementById('editorChatInput'),
sendBtnEl: document.getElementById('editorSendBtn'),
channelId: workspaceChatId,
standalone: true,
});
// Lifecycle
pane.renderMessages(msgs);
pane.streamResponse(resp, msgs);
pane.destroy();
```
Components can be instantiated multiple times on the same page (editor
has a ChatPane, notes has a ChatPane — independent instances).
### Server-Rendered Shell + JS Hydration
Components have a Go template partial for the server-rendered scaffold:
```html
{{template "chat-pane" dict "ID" "editor"}}
```
This renders the DOM structure with predictable IDs. The JS component
attaches to these IDs on DOMContentLoaded. No client-side DOM
construction for the initial layout.
---
## Layer 3: Surfaces
A surface is a full-page layout that composes components. Each surface
declares:
1. **What components it uses** (ChatPane, NoteEditor, FileTree, etc.)
2. **How they're arranged** (CSS grid/flex layout)
3. **What data it needs on load** (Go data loader)
4. **What JS runs on boot** (script block or module)
### Current Surfaces
| Surface | Components | Data Loader |
|---------|-----------|-------------|
| Chat | ChatPane, ProjectSidebar, ModelSelector, PersonaPicker | channels, personas, models, projects |
| Editor | FileTree, CodeEditor, ChatPane (assist) | workspace, files, models |
| Notes | NoteEditor, NoteGraph, ChatPane (assist) | notes, folders, graph |
| Admin | AuditLog, SettingsForm, Table (various) | users, configs, models, health |
| Settings | SettingsForm, KBPicker, PersonaPicker | profile, preferences, policies |
### Surface Registration
Today: hardcoded in Go (`pageEngine.RenderSurface("chat")`).
Future: manifest-driven. An admin-installed extension declares a
surface in its manifest:
```json
{
"surfaces": [{
"id": "triage-intake",
"route": "/s/triage",
"title": "Triage Intake",
"components": ["chat-pane", "settings-form"],
"data_requires": ["personas", "models"],
"script": "surfaces/triage.js",
"auth": "authenticated"
}]
}
```
The page engine reads registered surfaces, generates routes, assembles
data loaders from the declared requirements, and renders a shell
template that loads the surface's script.
**Route namespace:** Admin-created surfaces live under `/s/:slug` to
avoid collision with core routes.
### Data Loaders
Each surface declares what data it needs. The page engine has a
registry of data providers:
```
"personas" → loads personas for the current user
"models" → loads enabled models with health status
"channels" → loads user's channels
"workspace" → loads workspace by :wsId param
"notes" → loads notes list
...
```
The surface's `data_requires` field pulls from this registry. Data is
injected into the page as `window.__PAGE_DATA__` (existing pattern).
The surface's JS reads from there on boot — no waterfall of API calls
on page load.
---
## Layer 4: Banners
The banner is global chrome outside the surface boundary. It exists
at the top, bottom, or both — configured by the platform admin via
`PUT /admin/settings/banner`.
```json
{
"enabled": true,
"text": "DEVELOPMENT",
"position": "both|top|bottom",
"bg": "#007a33",
"fg": "#ffffff"
}
```
The Go template base layout renders banners before and after the
surface content area. CSS custom properties (`--banner-top-height`,
`--banner-bottom-height`, `--banner-bg`, `--banner-fg`) allow surfaces
to account for banner space without knowing banner state.
Banners are not configurable by extensions or themes. They are a
platform-level trust signal.
---
## Themes
A theme is a set of CSS custom property overrides applied to `<html>`.
```css
[data-theme="corporate"] {
--bg: #f5f5f5;
--text: #1a1a1a;
--accent: #0066cc;
--border: #d1d5db;
--font-ui: 'Inter', sans-serif;
--font-code: 'Fira Code', monospace;
--radius: 4px;
/* ... full property set */
}
```
### What a Theme Can Do
- Override any CSS custom property in the theme contract
- Change colors, fonts, border radii, spacing scale
- Switch between light and dark base palettes
- Apply to every primitive, component, and surface uniformly
### What a Theme Cannot Do
- Add or remove DOM elements
- Execute JavaScript
- Modify component behavior or layout
- Override banner appearance (platform chrome)
- Access APIs or user data
### Theme Contract
The set of CSS custom properties that all primitives read from. This
is the stable API between themes and the UI. Properties are namespaced:
```
--bg, --bg-secondary, --bg-tertiary (backgrounds)
--text, --text-secondary, --text-muted (typography)
--accent, --accent-hover, --accent-muted (interactive)
--border, --border-strong (edges)
--success, --warning, --danger (semantic)
--font-ui, --font-code (typefaces)
--radius, --radius-lg (shapes)
--shadow, --shadow-lg (elevation)
--banner-bg, --banner-fg (read-only, set by platform)
```
Themes are admin-installed. An admin uploads a CSS file that declares
a `[data-theme="name"]` rule set. Users can select from installed
themes in Settings → Appearance.
Built-in themes: `dark` (default), `light`. The system preference
auto-detection (`prefers-color-scheme`) continues to work.
---
## Extension Hooks
Extensions interact with the application through a defined set of
hooks. Each hook has a specific trigger point in the lifecycle and
a clear contract for what the extension receives and returns.
### Hook 1: Pre-Completion
**When:** After the user sends a message, before the API request fires.
**Receives:** The completion request object (model, messages, tools, etc.)
**Returns:** Modified request object (or unmodified to pass through).
**Use case:** Inject additional tools, modify system prompt, add context.
```js
ctx.hooks.preCompletion(request => {
request.tools.push(myCustomTool);
return request;
});
```
### Hook 2: Stream Processing
**When:** On each SSE chunk during streaming.
**Receives:** The chunk (content delta, tool_use, tool_result, etc.)
**Returns:** Modified chunk (or null to suppress).
**Use case:** Transform content, intercept tool calls, accumulate data.
```js
ctx.hooks.streamChunk((chunk, context) => {
if (chunk.type === 'tool_result' && chunk.name === 'image_gen') {
context.displayContent.push({ type: 'image', src: chunk.data.url });
return null; // suppress from text content
}
return chunk;
});
```
### Hook 3: Post-Render
**When:** After a message is rendered into the DOM.
**Receives:** The message container element, the message object.
**Returns:** Nothing (mutates DOM in place).
**Use case:** Add action buttons, wrap elements, enhance display.
This is how extensions compose with each other's output. Extension A
produces an image via display content. Extension B's post-render hook
finds `<img>` elements and wraps them with action buttons. They don't
know about each other — they agree on the DOM contract.
```js
ctx.hooks.postRender((containerEl, message) => {
containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
const actions = document.createElement('div');
actions.className = 'image-actions';
actions.innerHTML = '<button data-action="upscale">Upscale</button>';
img.parentElement.appendChild(actions);
});
});
```
### Hook 4: Display Content
**When:** After a completion finishes (all chunks received).
**Receives:** The assistant message object, accumulated display content.
**Returns:** Display content items to attach to the message.
Display content is **message-scoped, rendered inline, but excluded from
the LLM context window**. It's not in `messages.content` and not sent
back on the next turn.
```
Message
├── content (text — goes to LLM)
├── attachments (files — go to LLM via multimodal assembly)
└── display_content[] ← rendered inline, NOT sent to LLM
├── { type: "image", src: "...", extension_id: "img-gen" }
└── { type: "html", content: "<div>...", extension_id: "editor" }
```
**Storage:** Display content is persisted in a `display_content` JSONB
column on the message (or a junction table). It survives page reload
and is included in `GET /channels/:id/path` responses.
### Hook 5: Tool Bridge
**When:** The LLM invokes a tool registered by the extension.
**Receives:** Tool name and input parameters.
**Returns:** Tool result (string or structured).
Existing mechanism — the WebSocket tool bridge from EXTENSIONS.md.
Tool is registered via manifest, exposed to the LLM through the
tools list, and executed client-side (browser tier) or server-side
(starlark/sidecar tiers).
### Hook 6: Surface Injection
**When:** Surface boot (DOMContentLoaded).
**Receives:** The surface ID and available mount points.
**Returns:** Nothing (attaches to mount points).
Extensions declare which surfaces they target and which mount points
they use:
```json
{
"surface_injections": [{
"surface": "chat",
"mount_point": "side-panel",
"component": "my-panel.js"
}, {
"surface": "admin",
"mount_point": "section",
"section_id": "my-admin-section",
"label": "Image Gen Settings"
}]
}
```
Mount points are declared by each surface template:
```html
<div data-mount="side-panel"></div>
<div data-mount="section" data-section-id="..."></div>
```
---
## Trust Model
Two tiers. The admin is the trust boundary.
### Privileged (Admin-Installed)
| Capability | Allowed |
|-----------|---------|
| Full DOM access | Yes |
| Direct API calls (user's JWT) | Yes |
| Modify other extensions' output (post-render) | Yes |
| Create surfaces (own routes) | Yes |
| Register tools | Yes |
| Access all hook types | Yes |
| Go template data loader | Yes |
| Read `window.__PAGE_DATA__` | Yes |
The admin chose to install it. Same trust model as a VS Code extension
or a WordPress plugin — you trust the publisher.
### Sandboxed (User-Installed, Future)
| Capability | Allowed |
|-----------|---------|
| Shadow DOM / iframe only | Yes |
| API calls through message bridge | Yes (proxied, scoped) |
| Modify other extensions' output | No |
| Create surfaces | No |
| Register tools | Limited (user-scoped) |
| Access hooks | Post-render own output only |
| Go template data loader | No |
| Read `window.__PAGE_DATA__` | No |
Post-1.0 scope. Documented here to confirm the architecture
accommodates it without redesign.
---
## Extension Lifecycle
### Boot Sequence
```
1. Page loads (Go template renders surface shell + banner)
2. Primitives initialize (CSS loaded, JS factories available)
3. Components hydrate (attach to server-rendered DOM)
4. Extensions.boot() — idempotent, runs on every surface
a. Load extension manifests
b. Register hooks (pre-completion, post-render, etc.)
c. Register surface injections for current surface
d. Initialize tool bridges
5. Surface-specific JS runs (app.js for chat, editor-mode.js, etc.)
```
`Extensions.boot()` replaces the current chat-specific
`Extensions.loadAll()` / `Extensions.initAll()` pair and runs on
every surface.
### Manifest Declaration
```json
{
"id": "image-generator",
"name": "AI Image Generator",
"version": "1.0.0",
"tier": "browser",
"hooks": {
"pre_completion": "hooks/pre-completion.js",
"stream_chunk": "hooks/stream.js",
"post_render": "hooks/post-render.js"
},
"tools": [{
"name": "generate_image",
"description": "Generate an image from a text description",
"parameters": { ... }
}],
"surfaces": [{
"id": "image-gallery",
"route": "/s/gallery",
"title": "Image Gallery",
"components": ["chat-pane"],
"data_requires": ["channels"],
"script": "surfaces/gallery.js"
}],
"surface_injections": [{
"surface": "chat",
"mount_point": "message-actions",
"script": "injections/image-actions.js"
}],
"theme": null,
"settings_schema": { ... }
}
```
---
## Display Content — Data Model
### Message Extension
```sql
ALTER TABLE messages ADD COLUMN display_content JSONB DEFAULT '[]';
```
Array of display items:
```json
[
{
"type": "image",
"src": "data:image/png;base64,...",
"alt": "A cat in a spacesuit",
"extension_id": "image-generator",
"metadata": { "model": "dall-e-3", "size": "1024x1024" }
},
{
"type": "html",
"content": "<div class='chart'>...</div>",
"extension_id": "data-viz",
"metadata": {}
}
]
```
### API Contract
Display content is included in message objects returned by
`GET /channels/:id/path` and `GET /channels/:id/messages`.
It is **excluded** from the message array sent to the LLM provider
in `POST /chat/completions`. The completion handler strips
`display_content` when assembling the provider request.
### Rendering
During `renderMessages()`, after the message content is rendered,
each `display_content` item is rendered below the text content:
```html
<div class="message-content">
<p>Here's the image you requested:</p>
</div>
<div class="message-display-content">
<img src="..." alt="..." data-display-content data-extension="image-generator">
</div>
```
The `data-display-content` attribute is the DOM contract that
post-render hooks use to discover display content from any extension.
---
## Migration Path
| Current State | Target State |
|---------------|-------------|
| `ui-primitives.js` + `ui-primitives-additions.js` | Primitive catalog with stable factory API |
| `ChatPane.create()` (v0.22.7) | Component catalog with shared instance pattern |
| 5 Go template routes | Surface registry (hardcoded core, manifest-driven extensions) |
| `ctx.ui.replace()` / `ctx.ui.inject()` | Hook 6: Surface Injection with declared mount points |
| `ctx.surfaces.register()` | Manifest `surfaces` field → page engine route registration |
| Block renderers in `Extensions.initAll()` | `Extensions.boot()` on every surface |
| Message content only (text + attachments) | `display_content` column for render-only visual content |
| Block renderer pipeline (mermaid, katex) | Post-render hook + DOM contract (`data-display-content`) |
| `[data-theme]` with CSS variables | Theme contract: stable CSS custom property namespace |
---
## Implementation Sequence
This does not prescribe version numbers. It describes dependency order.
**Phase 1: Primitive consolidation**
- Audit `ui-primitives.js`, `ui-primitives-additions.js`, and Go
template components
- Define the primitive catalog and factory API
- Define the CSS custom property contract (theme API)
- Migrate existing callers to the consolidated API
**Phase 2: Component formalization**
- ChatPane already exists (v0.22.7). Formalize the instance pattern.
- Extract NoteEditor, FileTree, ModelSelector as components
- Each component gets a Go template partial + JS hydration
**Phase 3: Extension hooks**
- `Extensions.boot()` on every surface (v0.22.8)
- Pre-completion and post-render hooks
- Display content data model (message column + render pipeline)
- Stream processing hook
**Phase 4: Surface registry**
- Manifest-driven surface registration
- Dynamic route generation in page engine
- Data loader registry
- Mount point declaration and injection
**Phase 5: Theme system**
- Stable CSS custom property contract
- Admin theme upload
- User theme selection in Settings → Appearance
---
## Open Questions
1. **Display content storage:** JSONB column on messages vs. separate
`message_display_content` table. Column is simpler; table allows
independent lifecycle (delete display content without touching
message). Leaning column for KISS.
2. **Display content size limits:** Base64 images in JSONB could get
large. May need blob storage for display content with URL references
instead of inline data. Deferred until real usage patterns emerge.
3. **Extension ordering:** Post-render hooks from multiple extensions
run in what order? Manifest priority field? Installation order?
Alphabetical? Matters when Extension B adds buttons to Extension A's
images — A must render first.
4. **Surface layout persistence:** If a surface supports resizable
panes (editor: file tree width, chat pane width), where is that
persisted? User settings? Per-workspace? Per-surface?
5. **Extension settings storage:** Extensions need per-user config
(API keys for image gen, preferences for behavior). Current
`UpdateUserExtensionSettings` endpoint exists. Is the schema
sufficient or does it need structured validation from
`settings_schema` in the manifest?