Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+216
View File
@@ -0,0 +1,216 @@
---
name: clack-cli-patterns
description: Use when creating or modifying terminal CLI commands, prompts, or output formatting in OpenChamber. Enforces Clack UX standards with strict parity and safety across TTY/non-TTY, --quiet, and --json modes.
license: MIT
compatibility: opencode
---
## Overview
OpenChamber terminal CLI uses `@clack/prompts` for interactive UX, but command policy and validation must be mode-agnostic.
**Core principle:** policy-first, UX-second. Clack is presentation, not enforcement.
## Scope
Use this skill for terminal CLI work only (for example `packages/web/bin/*`).
Do not use this skill for web UI or VS Code webview styling work.
## Mandatory Rules
1. **Validation first**
- Safety and correctness checks must run in all modes.
- Prompts may help collect input, but cannot be the only guard.
2. **Mode parity is required**
- Behavior must be equivalent in:
- Interactive TTY
- Non-interactive shells
- `--quiet`
- `--json`
- Fully pre-specified flags
- Invalid operations must fail deterministically with non-zero exit code.
3. **Prompt guard contract**
- Only prompt when all are true:
- stdout is TTY
- not `--quiet`
- not `--json`
- not automated/non-interactive context
4. **Output contract**
- `--json`: machine-readable output only.
- `--quiet`: suppress non-essential output only.
- Neither mode weakens policy enforcement.
5. **Cancellation contract**
- Handle prompt cancellation with `isCancel` + `cancel(...)`.
- Handle SIGINT cleanly and use consistent exit semantics.
## Clack Primitive Standard
- **Flow framing:** `intro`, `outro`, `cancel`
- **Status lines:** `log.info`, `log.success`, `log.warn`, `log.error`, `log.step`
- **Guidance blocks:**
- default: `note`
- high-severity warnings only: `box`
- **Prompts:** `select`, `confirm`, `text`, `password`
- **Long-running feedback:**
- unknown duration: `spinner`
- known duration: `progress`
- multi-stage: `tasks`
## Preferred Pattern
Centralize Clack imports and formatting helpers in one adapter module (for example `cli-output.js`) so command logic stays focused on behavior and policy.
### Thin framework (recommended)
Use a small shared helper surface rather than command-specific formatting logic.
- `isJsonMode(options)`
- `isQuietMode(options)`
- `shouldRenderHumanOutput(options)`
- `canPrompt(options)`
- `createSpinner(options)`
- `createProgress(options, config)`
- `printJson(payload)`
Keep this layer minimal. Do not hide core validation or command semantics inside output helpers.
## Output Contracts by Mode
### `--quiet` contract
`--quiet` should still return essential result data.
- Read/list commands: emit concise machine-friendly lines (not framed Clack blocks).
- Action commands: emit one minimal success line and concise errors.
- Do not suppress required outcomes entirely.
Quiet output should still be complete enough for scripts and quick human scanning.
- Status-like commands should list all active items, not only `running`/`ok`.
- Prefer compact stable key tokens in quiet lines (for example `port 3000 pass:yes`).
### `--json` contract (strict)
- Output must be JSON only (no extra text before/after payload).
- Warnings/info should be represented in JSON fields (for example `status`, `messages`).
- Preserve non-zero exit codes for failures.
## Human UX Consistency
### Framing completeness
- If human flow uses `intro`, close with `outro` (or `outro('')` when you want structure without text).
- Avoid orphan frame/spinner artifacts (prefer `spinner.clear()` when a trailing spinner line is not wanted).
- If a structured summary section immediately follows a spinner, prefer `spinner.clear()` to avoid duplicate success lines.
### Progress feedback for visible operations
- For operations users wait on (start/stop/restart/tunnel lifecycle), show in-progress spinner in interactive mode.
- Resolve each spinner explicitly to done/error so users can see completion state at the same visual location.
- Keep quiet/json modes non-animated.
### Prompt flow design
- Ask required inputs in dependency order (for example hostname before token when token depends on chosen host/mode context).
- When offering save-vs-run flows, ask intent before collecting optional metadata (for example profile name only if user chooses save).
- Prefill editable values with `initialValue` (not only `placeholder`) so users can accept or edit quickly.
- Reuse latest relevant values when safe (for example last managed-local config path, last managed-remote hostname).
### Readability on narrow terminals
- Prefer short lines.
- Split long guidance into multiple detail lines.
- Use warning/info codes (`[CODE]`) when the message has follow-up docs or repeat use.
### Guidance tone
- Use `Optional Tips` for non-required next actions.
- Avoid wording that implies mandatory follow-up unless it is truly required.
### Guidance rendering style (preferred)
- Prefer structured status lines for reusable hints:
- `logStatus('info', '[CODE]', '<actionable command or short guidance>')`
- Use short, stable codes (for example `[START_PROFILE]`, `[PORT_MISMATCH]`) so users can quickly scan and recognize repeated guidance.
- Prefer this style over boxed notes for routine follow-up actions.
- Reserve `note`/boxed callouts for rare, high-context guidance where a long paragraph is truly necessary.
## Parity Verification Matrix
For each command/subcommand, manually verify:
1. default interactive TTY output
2. `--quiet` output (minimal but informative)
3. `--json` output (JSON-only)
4. non-TTY behavior (e.g. piped)
5. error path in both human and json modes
## Copy/Paste Snippets
### Prompt Guard
```js
if (canPrompt(options)) {
const value = await select({
message: 'Choose an option',
options: [{ value: 'a', label: 'Option A' }],
});
if (isCancel(value)) {
cancel('Operation cancelled.');
return;
}
}
```
### Non-Interactive Fallback
```js
if (!resolvedValue) {
if (canPrompt(options)) {
// prompt path
} else {
throw new Error('Missing required value. Provide --flag <value>.');
}
}
```
### Spinner Guard
```js
const spin = createSpinner(options);
spin?.start('Running operation...');
// ...work...
spin?.stop('Done');
```
### JSON vs Human Output
```js
if (options.json) {
printJson({ ok: true, data });
return;
}
intro('Operation');
log.success('Completed');
outro('done');
```
## Implementation Checklist
1. Add or update core validators first.
2. Ensure validators execute in all modes.
3. Add interactive Clack UX only as enhancement.
4. Verify parity between interactive and non-interactive flows.
5. Ensure script-safe deterministic failure behavior.
## References
- Policy source: `AGENTS.md` (CLI Parity and Safety Policy)
- Terminal CLI precedent: `packages/web/bin/cli.js`
- Output adapter precedent: `packages/web/bin/cli-output.js`
+144
View File
@@ -0,0 +1,144 @@
---
name: drag-to-reorder
description: Use when implementing drag-to-reorder / sortable lists or chips in OpenChamber with @dnd-kit — covers the correct setup for BOTH desktop and mobile (touch), the variable-width "stretch" fix, the wrapping multi-row strategy choice, and the pitfalls (infinite update loop, offset overlay) we already hit and fixed.
license: MIT
compatibility: opencode
---
## Overview
OpenChamber uses **@dnd-kit** (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) for drag-to-reorder. Getting it to feel right on both desktop and mobile — especially for chips/tags of **variable width that wrap onto multiple rows** — has several non-obvious gotchas. This skill captures the configuration that works, and the bugs to avoid.
**Reference implementations in this repo:**
- `packages/ui/src/components/chat/DraftPresetChips.tsx` — variable-width chips that wrap (the hardest case; mobile long-press).
- `packages/ui/src/components/ui/sortable-tabs-strip.tsx` — single-row scrollable tabs.
## When to Use
- Adding any reorderable list, row of chips/tags, or sortable grid.
- Fixing an existing sortable that stretches the dragged item, jumps/overlaps across rows, throws "Maximum update depth exceeded", or doesn't work on touch.
## The Five Rules (most important first)
### 1. Translate, not Transform — kills the "stretch to slot width" bug
`CSS.Transform.toString(transform)` emits `translate3d(...) scaleX(..) scaleY(..)`. For variable-width items the sorting strategy puts a non-1 scale on the lifted item so it **stretches to the neighbor/slot width**. Use `CSS.Translate.toString(transform)` — translation only, no scale — and the dragged item keeps its own size. Sibling shifting is unaffected (their scale was 1 anyway).
```tsx
import { CSS } from '@dnd-kit/utilities';
const { transform, transition, isDragging } = useSortable({ id });
<div style={{ transform: CSS.Translate.toString(transform), transition }} />
// ^^^^^^^^^ NOT CSS.Transform.toString
```
### 2. Pick the strategy by layout
| Layout | Strategy |
|--------|----------|
| Wraps onto multiple rows / grid / chips of variable width | `rectSortingStrategy` (default — computes 2D positions, so items can shift to other rows) |
| Guaranteed single horizontal row (e.g. scrollable tab strip) | `horizontalListSortingStrategy` |
| Single vertical list | `verticalListSortingStrategy` |
`horizontalListSortingStrategy` on a **wrapping** row is the classic mistake: it assumes one row, so dragging to another row makes items overlap instead of reflowing. Use `rectSortingStrategy` for anything that wraps.
### 3. Desktop + mobile = two sensors (MouseSensor + TouchSensor with delay)
Do NOT use a single `PointerSensor` — a distance constraint makes touch fight scrolling, and a delay constraint would force desktop to hold-before-drag. Split them:
```tsx
import { MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core';
const sensors = useSensors(
// Desktop: drag after a small move; a click still fires.
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
// Touch: long-press to drag — tap fires the element's onClick, a quick swipe scrolls.
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
```
This gives: tap = activate the item (e.g. submit), **long-press ≈200ms = drag**, swipe = scroll.
### 4. `touch-action: none` on the draggable
Without it the browser hijacks the touch gesture (scrolls) instead of letting dnd-kit drag. Add Tailwind `touch-none` (and usually `select-none`) to the draggable element.
### 5. Reorder on `onDragEnd`, with stable ids and `arrayMove`
```tsx
import { arrayMove } from '@dnd-kit/sortable';
const onDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
if (over && active.id !== over.id) {
// find by id, never by array index
const from = items.findIndex(i => i.id === active.id);
const to = items.findIndex(i => i.id === over.id);
setItems(arrayMove(items, from, to));
}
};
```
IDs must be **stable per item** (derive from the item's identity, e.g. `type:name`), never the array index — index ids break tracking after the first move.
## Minimal working pattern (wrapping, variable width, desktop + touch)
```tsx
import { DndContext, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, rectSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const Item: React.FC<{ id: string; label: string; onClick: () => void }> = ({ id, label, onClick }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }} className={cn(isDragging && 'opacity-60')}>
<button type="button" {...attributes} {...listeners} onClick={onClick} className="touch-none select-none ...">
{label}
</button>
</div>
);
};
const Row: React.FC<{ items: Item[]; onReorder: (next: Item[]) => void }> = ({ items, onReorder }) => {
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
const onDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
const from = items.findIndex(i => i.id === active.id);
const to = items.findIndex(i => i.id === over.id);
onReorder(arrayMove(items, from, to));
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={items.map(i => i.id)} strategy={rectSortingStrategy}>
<div className="flex flex-wrap gap-2">
{items.map(i => <Item key={i.id} id={i.id} label={i.label} onClick={i.onClick} />)}
</div>
</SortableContext>
</DndContext>
);
};
```
A clickable element can be draggable at the same time: keep `onClick` on the button and the activation constraint (distance/delay) lets a plain click/tap through.
## Pitfalls we already hit (don't repeat)
| Symptom | Cause | Fix |
|---------|-------|-----|
| Dragged item **stretches** to the target slot width | `CSS.Transform.toString` applies scaleX/scaleY | Use `CSS.Translate.toString` (Rule 1) |
| On narrow/multi-row: items **don't reflow to other rows, overlap**, unclear drop target | `horizontalListSortingStrategy` on a wrapping row | Use `rectSortingStrategy` (Rule 2) |
| **"Maximum update depth exceeded"** during drag + dragged element floats **offset from the cursor** | Live-reorder in `onDragOver` (empty strategy + `setState` each over) oscillates A↔B with variable sizes; the empty `DragOverlay` we paired with it was mispositioned | Don't reorder in `onDragOver`. Reorder once in `onDragEnd` (Rule 5). Only reach for live-reorder if you truly need physical row-reflow, and then guard against oscillation. |
| Touch drag scrolls the page instead of dragging | Missing `touch-action: none` | Add `touch-none` (Rule 4) |
| Touch: every finger move drags, or tap doesn't register | Single `PointerSensor` with distance | Split into MouseSensor + TouchSensor(delay) (Rule 3) |
## If `rectSortingStrategy` still isn't crisp enough
Reordering variable-width chips across wrapped rows is a documented rough edge in dnd-kit's box strategies. `rectSortingStrategy` is the best the strategy-based approach offers without instability. If a design needs bulletproof cross-row feedback, switch UX: render a **drop-position indicator** (a line/gap showing where it will land) and keep items static during the drag (no reorder until drop) — this avoids both overlap and the oscillation loop, at the cost of more code. Discuss before building it.
## Key Files
- Variable-width wrapping chips: `packages/ui/src/components/chat/DraftPresetChips.tsx`
- Single-row tab strip: `packages/ui/src/components/ui/sortable-tabs-strip.tsx`
- Library: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` (already in `packages/ui/package.json`)
+128
View File
@@ -0,0 +1,128 @@
---
name: locale-ui-patterns
description: Use when creating or modifying OpenChamber UI text, labels, buttons, placeholders, aria labels, empty states, toasts, dialogs, settings copy, navigation labels, or any user-facing strings.
---
# Locale UI Patterns
## Core Rule
User-facing UI text must go through `@/lib/i18n`; do not hardcode English strings in components.
Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states.
## Required Flow
1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`.
2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`.
3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`.
4. For locale names or language picker labels, use `label(locale)` from `useI18n()`.
5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`.
6. Do not remount the app to update language. Components must re-render through `useI18n()`.
## Component Usage Rules
- Import from `@/lib/i18n`, not deep files.
- Keep `t(...)` calls inside React render/hook scope so locale changes re-render text.
- Do not resolve translated text at module scope.
- For static option arrays, store `labelKey` / `descriptionKey`; resolve with `t(...)` inside the component.
- For non-React helpers, pass translated strings in from the component or pass `t` explicitly.
## Key Style
Use stable semantic keys, not English text as keys.
Keys should describe location + UI role + meaning. They should not encode current copy wording.
Use existing nearby naming when extending a surface. If no nearby pattern exists, choose a short path that mirrors the UI ownership.
Namespaces like `layout.*`, `settings.*`, `chat.*`, `git.*`, `session.*`, `toast.*`, and `dialog.*` are examples, not a fixed exhaustive list.
Good:
```ts
'settings.appearance.language.label': 'Language'
'layout.mainTab.chat': 'Chat'
'chat.input.placeholder': 'Ask OpenChamber...'
```
Bad:
```ts
'Language': 'Language'
'chatLabel': 'Chat'
'askOpenChamberDotDotDot': 'Ask OpenChamber...'
```
Avoid overly generic keys unless the text is truly global and context-independent. Prefer specific keys when button meaning can vary by surface.
## Parameters
Use `{name}` placeholders for dynamic values.
```ts
'toast.language.changed': 'Language changed to {language}'
```
```tsx
t('toast.language.changed', { language: label(locale) })
```
Do not pass grammar fragments as params. Never use params like `{suffix}`, `{plural}`, `{article}`, `{prefix}`, `{dateSuffix}`, or pieces of words/sentences.
Bad:
```tsx
t('dialog.delete.description', { count, suffix: count === 1 ? '' : 's' })
```
Good:
```tsx
count === 1
? t('dialog.delete.descriptionSingle', { count })
: t('dialog.delete.descriptionPlural', { count })
```
Plural/count-dependent text must use separate complete-message keys unless all supported locales can use one identical complete sentence. Placeholders are only for real values (`{count}`, `{name}`, `{path}`), not grammar.
Optional clauses must also be complete-message keys. Do not build a sentence by injecting a translated phrase into another translated sentence.
Bad:
```tsx
t('dialog.delete.description', {
dateLabel: date ? t('dialog.delete.dateSuffix', { date }) : '',
})
```
Good:
```tsx
date
? t('dialog.delete.descriptionWithDate', { count, date })
: t('dialog.delete.description', { count })
```
## What Counts As UI Text
- Button and menu labels
- Settings labels and descriptions
- Placeholder text
- Tooltip content
- Dialog titles/descriptions/actions
- Toast title/description/action labels
- Empty/error/loading states
- `aria-label`, `title`, image `alt` text when user-facing
## Exceptions
Do not translate:
- Product names: `OpenChamber`, `OpenCode`, `GitHub`
- Protocol/tool acronyms: `MCP`, `SSE`, `WebSocket`, `API`
- Model/provider names
- File paths, command names, environment variables
- User/generated content
## Review Checklist
- No new hardcoded user-facing English in changed UI files.
- Every new key exists in all dictionaries.
- No locale state added to broad/shared stores.
- No full app remount for locale changes.
- Locale switch preserves current UI state.
@@ -0,0 +1,238 @@
---
name: settings-ui-patterns
description: Use when creating or modifying UI components, styling, or visual elements related to Settings in OpenChamber.
license: MIT
compatibility: opencode
---
# Settings UI Patterns Skill
## Purpose
This skill provides instructions for creating or redesigning Settings pages, informational panels, and configuration interfaces within the OpenChamber application.
## Current Canonical Look (2026)
Use this as source of truth for new settings UI work.
- **Flat hierarchy first**: Prefer spacing + typography hierarchy over boxed backgrounds.
- **No unnecessary wrappers**: Avoid extra section wrappers that mix unrelated controls.
- **No redundant section titles**: Do not add headers like `Theme Preferences` or `Scaling & Layout` when controls are already self-explanatory.
- **Compact controls**: Option chips and radio rows should be dense, not tall.
- **Left-leading state icon**: Radio/checkbox state icon appears before text.
- **Subtle state contrast**: Inactive radio labels should be visibly dimmer than active labels.
- **Minimal row chrome**: Avoid row hover/background highlighting by default; keep only where explicitly needed.
## Typography Guidelines
Always utilize the standard OpenChamber typography classes defined in `packages/ui/src/lib/typography.ts`.
- **Page Title**: Use `typography-ui-header font-semibold text-foreground` for the top-most title of a settings page/dialog.
- **Section Header**: Use `typography-ui-header font-medium text-foreground` for settings sections (e.g. `Notification Events`, `Session Defaults`).
- **Control Group Header**: Use `typography-ui-header font-medium text-foreground` (or `font-normal` if it reads too loud) for grouped controls inside a section (e.g. `Default Tool Output`, `Diff Layout`).
- **Values / Primary Text**: Use `typography-ui-label text-foreground`. Add `tabular-nums` if displaying numbers or stats to ensure vertical alignment.
- **Option Labels**: Use non-bold label text in compact option controls (`font-normal` when needed to override).
- **Meta / Helper Text**: Use `typography-meta text-muted-foreground` or `typography-small text-muted-foreground` for supplemental text.
## Layout and Spacing Patterns
### 1. Main Backgrounds
Main wrappers should generally use `bg-background` or `bg-[var(--surface-background)]`. Ensure adequate padding (e.g., `px-5 py-6` or `p-6`).
### 2. Subsection Grouping
Group related controls with vertical spacing, not mandatory cards.
- Use `space-y-3` between logical subsections.
- Use `p-2` for subsection internal padding.
- Avoid adding `bg-[var(--surface-elevated)]` unless there is a clear reason.
- Avoid extra row decorations (`rounded-md`, hover fills) unless there is explicit UX value.
### 3. Header-to-Content Hierarchy (critical)
When removing cards/background wrappers, spacing must be rebalanced so header ownership stays clear.
- Keep **section-to-section spacing larger** than **header-to-own-content spacing**.
- Typical pattern:
- header wrapper `mb-1 px-1`
- content wrapper `pt-0 pb-2 px-2`
- outer section spacing `mb-8`
- Do not leave legacy `mb-3` style gaps after flattening a section; it makes headers look detached.
### 4. Headerless Blocks (when context is obvious)
If the page title already provides enough context, remove redundant local headers and place controls directly below the title.
- Example: project page identity controls can sit directly under project name/path.
- Tighten top gap for this pattern (e.g. top header `mb-4` instead of larger section spacing).
```tsx
<div className="space-y-3">
<section className="p-2">...</section>
<section className="p-2">...</section>
</div>
```
## Structural Patterns
### 1. Segmented Option Buttons (compact)
Use for short option sets where button-style segmented choice reads best (e.g. Default Tool Output).
```tsx
<div className="mt-1 flex flex-wrap items-center gap-1">
<ButtonSmall
variant="outline"
size="xs"
className={cn('!font-normal', isSelected ? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10' : 'text-foreground')}
>
Collapsed
</ButtonSmall>
</div>
```
### 2. Radio Option Lists (compact rows)
Use for mutually exclusive mode/layout settings (e.g. Diff Layout, Diff View Mode).
- Use shared `Radio` component from `@/components/ui/radio`.
- Icon first, label second.
- Row container compact: `py-0.5`.
- Inactive label can use `text-foreground/50`.
```tsx
<div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
<div className="flex w-full items-center gap-2 py-0.5">
<Radio checked={selected} onChange={onSelect} ariaLabel="Diff layout: Dynamic" />
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>Dynamic</span>
</div>
</div>
```
### 3. Checkbox Setting Rows
Use shared `Checkbox` component from `@/components/ui/checkbox` for boolean toggles.
- Icon first, text immediately after (`gap-2`).
- Typical row spacing for checkbox rows: `py-1.5`.
- Keep row click and keyboard toggle support.
- Prefer checkbox over binary show/hide button pairs for pure boolean state.
```tsx
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
>
<Checkbox checked={value} onChange={setValue} ariaLabel="Show Dotfiles" />
<span className="typography-ui-label text-foreground">Show Dotfiles</span>
</div>
```
### 4. Invisible Two-Column Alignment
Use consistent label/control columns across settings rows so controls align on a shared vertical line.
- Desktop row pattern: `flex items-center gap-8`
- Label column width: `w-56 shrink-0`
- Control cluster: `w-fit`
```tsx
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground w-56 shrink-0">Interface Font Size</span>
<div className="flex items-center gap-2 w-fit">...</div>
</div>
```
#### Disabled control rule
If a control is unavailable, disable the control only. Do not dim the label row by default.
#### Width-matching rule
When matching visual widths across different rows, compare full row footprint (control + adjacent action buttons), not just input width.
### 5. Theme Row Composition
For theme controls in Appearance:
- `Color Mode` header on first line; option chips below it.
- `Light Theme` and `Dark Theme` on one row where possible, wrapping on small widths.
- Keep selectors near labels and aligned to existing column rhythm.
- Replace persistent helper text with an info tooltip icon near the related action.
```tsx
<div className="grid grid-cols-1 gap-2 py-1.5 md:grid-cols-[14rem_auto] md:gap-x-8 md:gap-y-2">
<div className="flex min-w-0 items-center gap-2">Light Theme ...</div>
<div className="flex min-w-0 items-center gap-2">Dark Theme ...</div>
</div>
```
### 6. Numeric Controls in Settings
Use compact stepper input (`- value +`) plus reset button.
- Prefer shared `NumberInput` stepper style over slider + numeric combo in dense settings pages.
- Keep reset button adjacent to control (`gap-2`).
- Avoid using Tailwind `overflow-hidden` on mobile for controls; `packages/ui/src/styles/mobile.css` forces `.overflow-hidden { overflow-y: auto !important; }`.
Use `overflow-x-hidden overflow-y-hidden` if you truly need clipping.
- Touch devices: `packages/ui/src/styles/mobile.css` enforces `min-height: 36px` on `button`. If you build custom segmented controls with `<button>`, ensure the container height can accommodate that (e.g. `h-9`).
#### Optional numeric overrides
For "override unless empty" fields (e.g. agent Temperature/Top P), keep the value optional and provide a fallback for stepping.
```tsx
<NumberInput
value={temperature}
fallbackValue={0.7}
onValueChange={setTemperature}
onClear={() => setTemperature(undefined)}
min={0}
max={2}
step={0.1}
inputMode="decimal"
emptyLabel="—"
/>
```
```tsx
<div className="flex items-center gap-2 w-fit">
<NumberInput value={fontSize} onValueChange={setFontSize} min={50} max={200} step={5} />
<ButtonSmall variant="ghost" className="h-7 w-7 px-0">...</ButtonSmall>
</div>
```
### 7. Inputs and Select Triggers (settings density)
Keep form controls in settings compact and aligned.
- Prefer `Input` with `className="h-7"` in dense settings rows.
- Prefer default `SelectTrigger` sizing (avoid `size="lg"` in settings).
- For icon-only actions next to inputs, use `ButtonSmall` with `h-7 w-7 p-0`.
```tsx
<div className="flex items-center gap-2">
<Input className="h-7" />
<ButtonSmall variant="outline" size="xs" className="h-7 w-7 p-0" aria-label="Browse">
<RiFolderLine className="h-4 w-4" />
</ButtonSmall>
</div>
```
### 8. Template Grids (text fields)
For template-like settings (title/message pairs), use a simple grid and flat cells.
- Grid: `grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3`
- Cell: `section p-2`
- Field: `Input className="h-7"`
### 9. Icon/Color Picker Rows
For dense icon/color pickers in settings:
- Place options under the field label when they are a palette/grid choice.
- Use stable selected-state styling (`border`/`ring`/subtle background), avoid transform jumps (`scale-*`).
- Keep chip size compact (`h-7 w-7`) and spacing consistent (`gap-2`).
## Control Selection Rules
- **Use compact option buttons** for short, chip-like selection groups.
- **Use radios** for explicit mode/layout choices where list scanning is better.
- **Use checkboxes** for true/false settings.
- **Avoid show/hide button pairs** when a checkbox maps directly to the boolean.
- **Do not couple unrelated toggles** under one synthetic section header; keep hierarchy clear.
## Best Practices
- **Density**: Keep options compact; avoid oversized rows/chips in dense settings pages.
- **Consistency**: Reuse shared controls (`Checkbox`, `Radio`, `ButtonSmall size="xs"`) instead of inline icon logic.
- **Reuse via composition**: Prefer a single settings component with a `visibleSettings` subset (like `OpenChamberVisualSettings`) for multiple tabs (Appearance/Chat) instead of duplicating markup.
- **Hierarchy**: Page title = `font-semibold`; section header = `font-medium`; control group header = `font-medium` (or `font-normal` if needed); option labels = non-bold.
- **Subsection depth**: Nested subgroup headings under a section should usually be one step lighter than parent heading weight.
- **Hierarchy sanity check**: after flattening UI, verify visual grouping by spacing first (not color).
- **Helper blocks**: For small notes/errors under a section, use `mt-1 px-2` with `typography-meta text-muted-foreground/70` (and status token for errors).
- **Truncation**: Always consider long text. Use `min-w-0 flex-1 truncate` on text containers that sit next to buttons or icons to prevent layout breakage.
- **Theme Variables**: *Always* use CSS variables for colors (e.g., `var(--status-success)`) rather than hardcoded hex values or generic Tailwind colors when indicating semantic states.
+350
View File
@@ -0,0 +1,350 @@
---
name: theme-system
description: Use when creating or modifying UI components, styling, visual elements, or icons in OpenChamber. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes. All icons must use the shared Icon component from the SVG sprite system - never import from @remixicon/react directly.
license: MIT
compatibility: opencode
---
## Overview
OpenChamber uses a JSON-based theme system. Themes are defined in `packages/ui/src/lib/theme/themes/`. Users can also add custom themes via `~/.config/openchamber/themes/`.
**Core principle:** UI colors must use theme tokens - never hardcoded hex colors or Tailwind color classes.
## When to Use
- Creating or modifying UI components
- Working with colors, backgrounds, borders, or text
- **Working with icons — adding, changing, or creating icon usages**
## Quick Decision Tree
1. **Code display?** → `syntax.*`
2. **Feedback/status?** → `status.*`
3. **Primary CTA?** → `primary.*`
4. **Interactive/clickable?** → `interactive.*`
5. **Background layer?** → `surface.*`
6. **Text?** → `surface.foreground` or `surface.mutedForeground`
## Critical Rules
- `surface.elevated` = inputs, cards, panels
- `interactive.hover` = **ONLY on clickable elements**
- `interactive.selection` = active/selected states (not primary!)
- Status colors = **ONLY for actual feedback** (errors, warnings, success)
- Input footers = `bg-transparent` on elevated background
## Button Rules (MANDATORY)
Use only the shared `Button` component from `packages/ui/src/components/ui/button.tsx`.
- Do not create wrapper button components (for example `ButtonLarge`, `ButtonSmall`).
- Do not hardcode button height/padding classes when a `size` variant exists.
- Use semantic button variants consistently; avoid ad-hoc one-off button styling.
### Allowed Button Variants
| Variant | Use for | Token direction |
|-------|-------|-------|
| `default` | Primary action in a local section/dialog | `primary.*` |
| `outline` | Secondary visible action | `surface.elevated` + `interactive.*` |
| `secondary` | Soft secondary action | `interactive.hover` / `interactive.active` |
| `ghost` | Low-emphasis row/toolbar action | transparent + `interactive.hover` |
| `destructive` | Destructive actions (`Delete`, `Revert all`) | `status.error*` |
| `link` | Rare inline text action only | text-link style |
### Allowed Button Sizes
| Size | Use for |
|------|---------|
| `xs` | Dense controls in rows/lists |
| `sm` | Default compact action buttons |
| `default` | Standard form/page actions |
| `lg` | Prominent large actions |
| `icon` | Icon-only square button |
### Button Selection Quick Guide
1. Main CTA in section/dialog -> `default`
2. Side action next to CTA -> `outline`
3. Quiet auxiliary action -> `ghost`
4. Dangerous action -> `destructive`
5. Tiny row action -> keep same variant, set `size="xs"`
### Never Use
- Hardcoded hex colors (`#FF0000`)
- Tailwind colors (`bg-white`, `text-blue-500`, `bg-gray-*`)
- Deprecated: `bg-secondary`, `bg-muted`
## Usage
### Via Hook
```tsx
import { useThemeSystem } from '@/contexts/useThemeSystem';
const { currentTheme } = useThemeSystem();
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
```
### Via CSS Variables
```tsx
<div className="bg-[var(--surface-elevated)] hover:bg-[var(--interactive-hover)]">
```
## Color Tokens
### Surface Colors
| Token | Usage |
|-------|-------|
| `surface.background` | Main app background |
| `surface.elevated` | Inputs, cards, panels, popovers |
| `surface.muted` | Secondary backgrounds, sidebars |
| `surface.foreground` | Primary text |
| `surface.mutedForeground` | Secondary text, hints |
| `surface.subtle` | Subtle dividers |
### Interactive Colors
| Token | Usage |
|-------|-------|
| `interactive.border` | Default borders |
| `interactive.hover` | Hover on **clickable elements only** |
| `interactive.selection` | Active/selected items |
| `interactive.selectionForeground` | Text on selection |
| `interactive.focusRing` | Focus indicators |
### Status Colors
| Token | Usage |
|-------|-------|
| `status.error` | Errors, validation failures |
| `status.warning` | Warnings, cautions |
| `status.success` | Success messages |
| `status.info` | Informational messages |
Each has variants: `*`, `*Foreground`, `*Background`, `*Border`.
### Primary Colors
| Token | Usage |
|-------|-------|
| `primary.base` | Primary CTA buttons |
| `primary.hover` | Hover on primary elements |
| `primary.foreground` | Text on primary background |
**Primary vs Selection:** Primary = "click me" (CTA), Selection = "currently active" (state).
### Syntax Colors
For code display only. Never use for UI elements.
| Token | Usage |
|-------|-------|
| `syntax.base.background` | Code block background |
| `syntax.base.foreground` | Default code text |
| `syntax.base.keyword` | Keywords |
| `syntax.base.string` | Strings |
| `syntax.highlights.diffAdded` | Added lines |
| `syntax.highlights.diffRemoved` | Removed lines |
## Examples
### Input Area
```tsx
const { currentTheme } = useThemeSystem();
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea className="bg-transparent" />
<div className="bg-transparent">{/* Footer - transparent! */}</div>
</div>
```
### Active Tab
```tsx
<button className={isActive
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover/50'
}>
```
### Error Message
```tsx
<div style={{
color: currentTheme.colors.status.error,
backgroundColor: currentTheme.colors.status.errorBackground
}}>
```
### Card
```tsx
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<h3 style={{ color: currentTheme.colors.surface.foreground }}>Title</h3>
<p style={{ color: currentTheme.colors.surface.mutedForeground }}>Description</p>
</div>
```
## Icon System (MANDATORY)
OpenChamber uses an SVG sprite-based icon system. **Never import from `@remixicon/react`.** Always use the shared `Icon` component.
### Import
```tsx
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
```
### Usage
```tsx
<Icon name="arrow-down-s" className="h-4 w-4" />
<Icon name="loader-4" className="size-4 animate-spin" />
```
### Naming Convention
Convert Remixicon component names to kebab-case sprite names:
1. Strip `Ri` prefix
2. Strip `Line` suffix
3. Convert PascalCase to kebab-case
4. Lowercase everything
| Remixicon | Sprite name |
|-----------|-------------|
| `RiArrowDownSLine` | `arrow-down-s` |
| `RiCheckLine` | `check` |
| `RiLoader4Line` | `loader-4` |
| `RiGithubFill` | `github-fill` |
| `RiBrainAi3Line` | `brain-ai-3` |
### Fill Variants
For filled (solid) icon variants, append `-fill` explicitly. The generator tries `Line` suffix first, then `Fill`, then bare name.
```tsx
<Icon name="github-fill" /> {/* RiGithubFill */}
<Icon name="github" /> {/* RiGithubLine (default) */}
```
### Sizing
The `Icon` component has **no `size` prop**. Use Tailwind classes:
```tsx
<Icon name="check" className="h-4 w-4" /> {/* 16px - most common */}
<Icon name="check" className="size-5" /> {/* 20px */}
<Icon name="check" className="h-3 w-3" /> {/* 12px */}
```
### Adding a New Icon (Workflow)
**In order:**
1. Use the icon in code with the correct kebab-case name:
```tsx
<Icon name="new-icon-name" className="h-4 w-4" />
```
2. If used as a value (not JSX), use `IconName` type:
```tsx
const config = { icon: "new-icon-name" as const };
```
3. Regenerate the sprite:
```bash
bun run icons:generate
```
4. The script scans all source files, reverse-maps to Remixicon names, extracts SVG paths, and regenerates `sprite.ts`.
5. Verify: `bun run type-check`
**Do NOT manually edit `sprite.ts`.** Always regenerate.
### Type Safety for Icon Values
When icons are stored in objects/arrays, change the type from `ComponentType` to `IconName` and render via `<Icon name={value} />`:
```tsx
// ❌ Old: component reference
const items = [{ icon: RiStackLine }];
return <items[0].icon className="h-4 w-4" />;
// ✅ New: IconName string
import type { IconName } from "@/components/icon/icons";
const items: { icon: IconName }[] = [{ icon: "stack" }];
return <Icon name={items[0].icon} className="h-4 w-4" />;
```
## Wrong vs Right
### Wrong
```tsx
// ❌ Importing from @remixicon/react
import { RiArrowDownSLine } from "@remixicon/react";
<RiArrowDownSLine className="h-4 w-4" />
// ❌ Hardcoded colors
<div style={{ backgroundColor: '#F2F0E5' }}>
<button className="bg-blue-500">
// Primary for active tab
<Tab className="bg-primary">Active</Tab>
// Hover on static element
<div className="hover:bg-interactive-hover">Static card</div>
// Colored footer on input
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea />
<div style={{ backgroundColor: currentTheme.colors.surface.muted }}>Footer</div>
</div>
```
### Right
```tsx
// ✅ Using the Icon component
import { Icon } from "@/components/icon/Icon";
<Icon name="arrow-down-s" className="h-4 w-4" />
// Theme tokens
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<button style={{ backgroundColor: currentTheme.colors.primary.base }}>
// Selection for active tab
<Tab style={{ backgroundColor: currentTheme.colors.interactive.selection }}>Active</Tab>
// Hover only on clickable
<button className="hover:bg-[var(--interactive-hover)]">Click</button>
// Transparent footer
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea className="bg-transparent" />
<div className="bg-transparent">Footer</div>
</div>
```
## References
- **[Adding Themes](references/adding-themes.md)** - Built-in and custom themes
## Key Files
- Theme types: `packages/ui/src/types/theme.ts`
- Theme hook: `packages/ui/src/contexts/useThemeSystem.ts`
- CSS generator: `packages/ui/src/lib/theme/cssGenerator.ts`
- Built-in themes: `packages/ui/src/lib/theme/themes/`
- Icon component: `packages/ui/src/components/icon/Icon.tsx`
- Icon sprite data: `packages/ui/src/components/icon/sprite.ts` (auto-generated)
- Icon types: `packages/ui/src/components/icon/icons.ts`
- Icon sprite generator: `scripts/generate-icon-sprite.mjs`
- Icon docs: `packages/ui/src/components/icon/README.md`
@@ -0,0 +1,51 @@
---
title: Adding Themes
---
# Adding Themes
## Custom Themes (User)
Drop a JSON file into `~/.config/openchamber/themes/`. No rebuild needed.
1. Create theme file (e.g., `my-theme.json`)
2. In app: **Settings → Theme → Reload themes**
3. Select from dropdown
See `docs/CUSTOM_THEMES.md` for full format reference.
## Built-in Themes (Development)
### 1. Create JSON Files
Add to `packages/ui/src/lib/theme/themes/`:
- `<id>-light.json`
- `<id>-dark.json`
Use existing themes (e.g., `flexoki-dark.json`) as reference for the full structure.
### 2. Register in presets.ts
```typescript
import mytheme_light_Raw from './mytheme-light.json';
import mytheme_dark_Raw from './mytheme-dark.json';
export const presetThemes: Theme[] = [
// ... existing themes
mytheme_light_Raw as Theme,
mytheme_dark_Raw as Theme,
];
```
### 3. Validate
```bash
bun run type-check && bun run lint && bun run build
```
## Key Files
- Theme types: `packages/ui/src/types/theme.ts`
- Presets: `packages/ui/src/lib/theme/themes/presets.ts`
- Example: `packages/ui/src/lib/theme/themes/flexoki-dark.json`
- Custom themes doc: `docs/CUSTOM_THEMES.md`
+306
View File
@@ -0,0 +1,306 @@
---
name: ui-api-decoupling
description: Use when creating or modifying OpenChamber UI data access, RuntimeAPIs, runtimeFetch/runtime-url auth, authenticated browser assets, OpenCode SDK calls, VS Code bridges, Electron runtime switching, or web server API endpoints.
license: MIT
compatibility: opencode
---
## Overview
OpenChamber shared UI runs against web, Electron desktop, remote server URLs, and VS Code webviews. API code must preserve that runtime boundary.
**Core principle:** official OpenCode API calls go through `@opencode-ai/sdk/v2` via `opencodeClient`; OpenChamber-owned capabilities go through `RuntimeAPIs` or explicit OpenChamber routes; runtime transport preserves SDK-generated requests exactly.
## Scope
Use this skill for changes touching UI data loading, session/message operations, provider/auth/config calls, filesystem/git/terminal/settings APIs, runtime switching, desktop/VS Code bridges, or server routes under `/api/*`.
Do not use this skill for pure visual-only UI work unless the change adds, removes, or reshapes data access.
## First Step
Before editing, classify every endpoint or capability involved:
| Need | Correct path |
|------|--------------|
| Official OpenCode endpoint | `opencodeClient` or `opencodeClient.getSdkClient()` |
| SDK gap to official OpenCode | Central helper in `opencodeClient` using `runtimeFetch`, documented as SDK gap |
| OpenChamber-owned feature route | `RuntimeAPIs` first, otherwise `runtimeFetch` to explicit OC route |
| Native/runtime capability | Extend `RuntimeAPIs`, implement per runtime, consume via hook/registry |
| Browser/realtime URL that cannot send headers (iframe, download/open link, SSE, WebSocket, preview subresource) | `getRuntimeUrlResolver()` helpers plus `oc_url_token` allowlist, not hardcoded URLs |
| UI-controlled authenticated asset fetch (small icons/thumbnails where JS can fetch) | `runtimeFetch` with `Authorization`, then `URL.createObjectURL(blob)` |
## Mandatory Rules
1. **Never bypass the SDK for official OpenCode APIs**
- Do not add raw `fetch` or direct `runtimeFetch` from feature UI to official endpoints such as `/api/session`, `/api/permission`, `/api/question`, `/api/auth`, `/api/provider`, `/api/command`, `/api/app`.
- Use `opencodeClient` wrappers or `opencodeClient.getSdkClient()`.
- If the SDK lacks a method, add a narrow wrapper in `packages/ui/src/lib/opencode/client.ts`, mark it as an SDK gap, and add transport coverage when body/method/query/signal matters.
2. **Preserve SDK request fidelity**
- Runtime transport must preserve `Request` method, body, headers, query string, auth, and abort signal.
- Do not rebuild a request from only `url` and `init`.
- Regression tests belong near `packages/ui/src/lib/runtime-fetch.test.ts`, `packages/vscode/webview/api/bridge.test.ts`, and proxy tests when transport changes.
3. **Use `RuntimeAPIs` for runtime-owned capabilities**
- Files, git, terminal, settings, notifications, GitHub helpers, client auth, editor/VS Code actions, and tools belong in `RuntimeAPIs` when shared UI needs runtime-specific behavior.
- React components use `useRuntimeAPIs()` or `useRuntimeAPI()`.
- Non-React modules use `getRegisteredRuntimeAPIs()` only when a hook cannot be used.
- Direct `window.__OPENCHAMBER_RUNTIME_APIS__` reads are entrypoint/legacy escape hatches, not a new feature pattern.
4. **Keep OpenChamber routes explicit**
- Direct `runtimeFetch` is acceptable for OpenChamber-only routes such as `/api/config/settings`, `/api/config/skills`, `/api/config/commands`, `/api/fs`, `/api/git`, `/api/terminal`, `/api/preview`, `/api/magic-prompts`, `/api/tts`, and `/api/openchamber/tunnel`.
- Register OpenChamber routes before the generic OpenCode proxy, or the proxy will steal the path.
- Shared UI depending on an OC route requires web and VS Code parity, or an explicit deterministic unsupported response.
5. **Do not hardcode local runtime URLs**
- Do not infer `localhost`, server ports, or `/api` origins in shared UI.
- Use `getRuntimeUrlResolver()` at call time.
- Do not use the exported `runtimeUrl` singleton for new code because it can capture stale resolver state.
6. **Treat runtime auth as transport state**
- HTTP auth is owned by `runtime-auth` and `runtimeFetch`; callers pass route paths and let transport attach `Authorization` only for the active runtime service URL.
- Browser/realtime transports that cannot set headers use `runtime-url` helpers and short-lived `oc_url_token` query auth.
- Never put long-lived client bearer tokens in URLs. `oc_client_token` should appear only in legacy stripping/rejection paths, tests, or migration compatibility code.
- Do not manually append `oc_url_token`; use resolver helpers and add server-side allowlist coverage when a new browser-consumed route needs URL auth.
7. **Runtime switch must reset stale state**
- Runtime base URL, runtime key, bearer token, SDK clients, terminal transports, session memory, and UI runtime-scoped state must not be cached blindly.
- Use `switchRuntimeEndpoint`, `subscribeRuntimeEndpointChanged`, `opencodeClient.reconnectToRuntimeBaseUrl()`, and runtime-keyed store state.
8. **Authoritative fetches must signal failure**
- If a caller uses returned data to replace, delete, or clear authoritative state, the method must throw or return `null` on failure.
- Do not swallow errors and return `[]`, `{}`, or `null` when that value is also a valid empty success unless the caller treats it as display-only.
9. **Privileged runtime switching requires explicit user intent**
- Electron connect/deep-link flows that import a remote host, store a client token, change default host, or switch active runtime must show an in-app confirmation before writing config or switching.
- The confirmation may show the label and server URL, but never the token.
- Existing-host imports still require confirmation because they can overwrite the stored token or change the active runtime.
## HTTP Request Decision Rules
For normal HTTP requests to the active OpenChamber runtime, use `runtimeFetch` with the route path. Let `runtimeFetch` resolve the current runtime base URL and auth at call time.
```ts
// Good: runtimeFetch owns base URL, runtime auth, and runtime switching.
await runtimeFetch('/health');
await runtimeFetch('/auth/session', { method: 'GET' });
await runtimeFetch('/api/config/settings');
await runtimeFetch('/api/fs/raw', { query: { path: absolutePath } });
// Bad: callers should not prebuild runtime HTTP URLs for fetches.
await fetch(getRuntimeUrlResolver().health());
await runtimeFetch(getRuntimeUrlResolver().api('/api/config/settings'));
await runtimeFetch(getRuntimeUrlResolver().rawFile(absolutePath));
```
Use `runtimeFetch(..., { query })` instead of manually appending query strings when the request targets `/api`, `/auth`, or `/health`.
```ts
// Good
await runtimeFetch('/api/git/status', { query: { directory, mode: 'light' } });
// Avoid
await runtimeFetch(`/api/git/status?directory=${encodeURIComponent(directory)}&mode=light`);
```
Use `getRuntimeUrlResolver()` only when the resulting URL is consumed by the browser or a realtime transport, not immediately fetched as HTTP:
```ts
// Good resolver usage: URL is assigned to browser/realtime consumers.
const rawImageSrc = getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
const iframeSrc = getRuntimeUrlResolver().authenticatedAsset(proxyPath);
const eventUrl = getRuntimeUrlResolver().sse('/api/event');
const socketUrl = getRuntimeUrlResolver().websocket('/api/terminal/ws');
```
Plain `fetch` is acceptable only for intentional external network requests that do not target the OpenChamber runtime, such as npm registry, models.dev, or a user-provided `https://...` URL.
## Authenticated Browser Assets
Authenticated assets need an explicit transport choice. Pick based on who owns the request:
| Asset/request shape | Correct pattern |
|---------------------|-----------------|
| React/UI code can fetch it and the object is small (project icons, small thumbnails, generated previews) | `runtimeFetch('/api/...')` with `Authorization`, read `blob()`, render a `URL.createObjectURL(blob)` |
| Browser must own the URL (iframe `src`, image/download/open-link for large raw files, rewritten preview subresources) | `getRuntimeUrlResolver().authenticatedAsset(...)` so the URL carries short-lived `oc_url_token` |
| Realtime transports | `getRuntimeUrlResolver().sse(...)` or `.websocket(...)`; never generic fetch/proxy paths |
For object-URL assets:
- Key caches by runtime identity (`getRuntimeApiBaseUrl()` or runtime key), entity ID, version/update timestamp, and render-affecting options.
- Cap caches and revoke evicted object URLs with `URL.revokeObjectURL`.
- Render a deterministic fallback while loading or after failure; do not leave empty chrome.
- Keep the fetch display-only unless the caller intentionally treats failure as authoritative.
For URL-auth assets:
- The server route must explicitly allow `oc_url_token` in `packages/web/server/lib/ui-auth/ui-auth.js` and have coverage in `ui-auth.test.js`.
- Scope allowlists narrowly to browser-readable GET routes or specific realtime upgrade paths. Do not allow arbitrary `/api/*`.
- Use short-lived `oc_url_token` only. Do not revive `oc_client_token` in query strings.
Preview iframe/subresource rules:
- Use preview proxy helpers so `oc_preview_token` and `oc_url_token` propagate to rewritten resources and redirects.
- Strip legacy `oc_client_token` before forwarding to dev servers.
- Do not use `postMessage('*')`; target the known preview origin.
- Preserve CSP where possible. If injecting a bridge, prefer a per-response nonce and remove only directives that block framing or the bridge.
## Runtime API Extension Pattern
When adding a native/per-runtime capability:
1. Add or extend the interface in `packages/ui/src/lib/api/types.ts`.
2. Implement web HTTP behavior in `packages/web/src/api/*` and compose it in `packages/web/src/api/index.ts`.
3. Implement VS Code webview API in `packages/vscode/webview/api/*` and compose it in `packages/vscode/webview/api/index.ts`.
4. Add extension-host handlers in `packages/vscode/src/bridge-*-runtime.ts` when filesystem, git, settings, or OpenCode manager access is required.
5. Keep Electron shared through the web runtime unless it needs shell-only IPC in `packages/electron/main.mjs` or `packages/electron/preload.mjs`.
6. Register the runtime APIs through app entrypoints and consume through `RuntimeAPIProvider`.
## VS Code Route Parity
For any shared UI call to `/api/*`, decide the VS Code behavior explicitly:
| Route type | VS Code handling |
|------------|------------------|
| OpenChamber local route | Handle in `packages/vscode/webview/main.tsx` and bridge to extension host when needed |
| Official OpenCode route | Let generic fetch proxy forward to OpenCode via `api:proxy` |
| SSE route | Use `api:sse:start` / stream messages / `api:sse:stop`, never generic proxy |
| Session message POST | Use `api:session:message` special proxy path |
| Unsupported native feature | Return stable 501/unsupported JSON, not silent fallback |
## Electron Security Boundary
Electron exposes API base and shell identity broadly, but privileged local capabilities stay local-only.
- `__OPENCHAMBER_API_BASE_URL__` and `__OPENCHAMBER_LOCAL_ORIGIN__` route requests.
- `__OPENCHAMBER_CLIENT_TOKEN__`, `__OPENCHAMBER_HOME__`, and `__TAURI__`-style IPC are local-page gated.
- Do not expose filesystem, shell, or host secrets to remote pages for UI convenience.
- Do not trust arbitrary loopback, `file://`, or `about:blank` origins as local UI. Gate privileged preload/IPC/token access to the packaged UI origin and exact runtime origins.
- Deep-links that add or switch remote runtimes are trust-boundary changes. Confirm before storing tokens or switching hosts.
## Common Anti-Patterns
| Anti-pattern | Use instead |
|--------------|-------------|
| `fetch('/api/session/...')` in shared UI | SDK through `opencodeClient` |
| `runtimeFetch('/api/session/...')` from a component | SDK wrapper or documented SDK-gap helper |
| `fetch(getRuntimeUrlResolver().health())` | `runtimeFetch('/health')` |
| `runtimeFetch(getRuntimeUrlResolver().api('/api/foo'))` | `runtimeFetch('/api/foo')` |
| `runtimeFetch(getRuntimeUrlResolver().rawFile(path))` | `runtimeFetch('/api/fs/raw', { query: { path } })` |
| New `/api/foo` only in web server | Web + VS Code route decision |
| Component reads `window.__OPENCHAMBER_RUNTIME_APIS__` | `useRuntimeAPIs()` / `useRuntimeAPI()` |
| Rebuilding `new Request(newUrl)` only | `new Request(newUrl, oldRequest)` plus merged headers |
| Returning `[]` on authoritative SDK failure | Throw or return `null` and preserve state |
| Caching `getRuntimeUrlResolver()` output forever | Read resolver/client at call time or reset on runtime switch |
| Manually appending `oc_client_token` or `oc_url_token` | `runtimeFetch` for HTTP, resolver helpers for browser/realtime URLs |
| Direct `<img src>` to a small authenticated app asset | `runtimeFetch` + `blob()` + object URL with fallback and bounded cache |
| Adding URL-auth access to a route without server allowlist tests | Narrow `oc_url_token` allowlist in `ui-auth.js` plus `ui-auth.test.js` coverage |
| Connect deep-link writes host config before consent | Confirm first, then import/switch |
## Verification Checklist
Before finalizing a UI/API decoupling change:
1. Official OpenCode routes use SDK wrappers or documented SDK-gap helpers.
2. OpenChamber routes are registered before the generic proxy.
3. VS Code has parity, proxy fallback, or explicit unsupported behavior.
4. Runtime transport preserves body, method, headers, query, auth, and abort signal.
5. Runtime auth/token handling uses `runtime-auth` and `runtime-url`.
6. No long-lived client bearer token is placed in a URL; browser/realtime URL auth uses scoped short-lived `oc_url_token` only.
7. Browser-consumed routes that need `oc_url_token` have narrow server allowlist and tests.
8. Runtime switch clears or scopes affected client/store/object-URL state.
9. Authoritative loaders distinguish failure from empty success.
10. Targeted tests cover changed transport, bridge, proxy, auth allowlist, or runtime API behavior.
## Implementation Map
### Shared UI Sources Of Truth
`packages/ui/src/lib/opencode/client.ts` is the central OpenCode SDK wrapper. It creates `@opencode-ai/sdk/v2` clients with `fetch: runtimeFetch`, runtime auth headers, current-directory handling, scoped clients, and convenience wrappers. Add official OpenCode API behavior here unless a feature directly consumes `getSdkClient()` in sync/runtime code.
`packages/ui/src/lib/runtime-fetch.ts` rewrites `/api`, `/auth`, and `/health` through the active runtime URL resolver and injects runtime auth. Its key contract is preserving SDK-created `Request` objects, including method, body, headers, query, and signal. For ordinary HTTP calls, pass route paths directly to `runtimeFetch`; do not pre-resolve them with `getRuntimeUrlResolver()` first.
`packages/ui/src/lib/runtime-url.ts` owns HTTP, auth, health, raw-file, SSE, WebSocket, and authenticated browser URL construction. `getRuntimeUrlResolver()` is the call-time source for browser-consumed URLs like iframe `src`, large/raw image `src`, download/open links, SSE URLs, and WebSocket URLs. `runtimeUrl` is not safe for new code that must survive runtime switches.
`packages/ui/src/lib/runtime-auth.ts` owns bearer-token state and short-lived URL-token minting. `runtimeFetch` merges `Authorization` unless a caller already supplied one. Runtime URL helpers add scoped `oc_url_token` where headers are impossible; they must never expose long-lived client bearer tokens in URLs.
### Runtime API Contract
`packages/ui/src/lib/api/types.ts` defines `RuntimeAPIs` and all per-runtime capability contracts.
`packages/ui/src/contexts/RuntimeAPIProvider.tsx` provides APIs to React and wraps `files` with a content cache that invalidates on write, delete, and rename.
`packages/ui/src/hooks/useRuntimeAPIs.ts` is the React consumption path. `packages/ui/src/contexts/runtimeAPIRegistry.ts` is the non-React escape hatch for modules that cannot use hooks.
`packages/ui/src/App.tsx` and app variants register APIs and reset runtime-scoped stores on `openchamber:runtime-endpoint-changed`.
### Web Runtime
`packages/web/src/runtimeConfig.ts` reads injected globals, configures the runtime URL resolver, sets the runtime bearer token, installs the runtime fetch bridge, and creates web APIs.
`packages/web/src/main.tsx`, `mobile-main.tsx`, and `mini-chat-main.tsx` assign `window.__OPENCHAMBER_RUNTIME_APIS__` before rendering shared UI.
`packages/web/src/api/index.ts` composes web `RuntimeAPIs` from implementations such as `files.ts`, `git.ts`, `terminal.ts`, `settings.ts`, `permissions.ts`, `github.ts`, `clientAuth.ts`, `push.ts`, and `tools.ts`.
Web runtime API implementations are normally HTTP clients for OpenChamber-owned server routes. Use `runtimeFetch` for HTTP requests; use `getRuntimeUrlResolver()` only when producing browser/realtime URLs that will not be immediately fetched by code.
### Server Routes And Proxy
`packages/web/server/index.js` starts the OpenChamber web server. Electron imports this server in-process.
`packages/web/server/lib/opencode/core-routes.js` installs JSON parsing for OpenChamber-owned `/api/*` route families.
`packages/web/server/lib/opencode/feature-routes-runtime.js` registers OpenChamber feature routes before the generic OpenCode proxy: filesystem, git, GitHub, quota, config entities, skills/plugins, magic prompts, session folders, scheduled tasks, and related features.
`packages/web/server/lib/opencode/proxy.js` is the generic `/api/*` proxy to upstream OpenCode. It strips the `/api` prefix, injects OpenCode auth headers, replays parsed bodies for non-GET requests, handles `/api/event` and `/api/global/event` as SSE, applies readiness gating, and canonicalizes directory query parameters.
OpenChamber-owned routes must be explicit and registered before the proxy. If a route is shared UI contract, add VS Code parity or a deterministic unsupported response.
If an OpenChamber route is consumed directly by the browser with `oc_url_token`, update the readable/realtime allowlist in `packages/web/server/lib/ui-auth/ui-auth.js` and add tests in `ui-auth.test.js`. Do not use URL tokens as a blanket `/api/*` auth bypass.
### VS Code Runtime
`packages/vscode/webview/api/index.ts` composes VS Code `RuntimeAPIs`. Terminal is a stub; files, git, settings, permissions, notifications, GitHub, tools, editor, and VS Code actions use the bridge.
`packages/vscode/webview/main.tsx` installs `window.__OPENCHAMBER_RUNTIME_APIS__` and overrides `window.fetch`. It handles OpenChamber local routes, then proxies generic OpenCode `/api/*` calls to the extension host. It has special branches for SSE and session message POST.
`packages/vscode/webview/requestBodyTransport.ts` extracts request bodies from SDK-style `Request` objects and `init.body` without losing bytes.
`packages/vscode/webview/api/bridge.ts` sends bridge messages, supports abort propagation, exposes `proxyApiRequest`, `proxySessionMessageRequest`, and SSE start/stop helpers.
`packages/vscode/src/bridge-proxy-runtime.ts` forwards generic OpenCode proxy requests to the live OpenCode API URL, merges sanitized headers with OpenCode auth, forwards body bytes, and rejects SSE through the generic proxy.
`packages/vscode/src/bridge-config-runtime.ts`, `bridge-fs-runtime.ts`, `bridge-git-runtime.ts`, and related bridge modules implement OpenChamber-owned route behavior in the extension host.
### Electron Runtime
`packages/electron/main.mjs` starts the web server in-process, resolves local/remote runtime target, tracks `apiBaseUrl` and `clientToken`, injects init scripts, confirms remote connect deep-links before storing tokens, and handles host switching.
`packages/electron/preload.mjs` exposes runtime globals. API base and local origin are broadly available for routing. Client token, home directory, and `__TAURI__` IPC stay local-page gated so remote pages cannot access local host capabilities.
Shared UI should not branch on Electron for backend behavior. Prefer web runtime APIs and the preload-provided `__TAURI__` compatibility shim only for shell capabilities that already exist in the shared runtime contract.
### Runtime Switch Flow
`packages/ui/src/lib/runtime-switch.ts` updates `__OPENCHAMBER_API_BASE_URL__`, `__OPENCHAMBER_CLIENT_TOKEN__`, runtime URL resolver, bearer token, and dispatches `openchamber:runtime-endpoint-changed`.
`packages/ui/src/App.tsx` reacts by preparing/restoring runtime-keyed session and UI state, reconnecting `opencodeClient`, clearing provider/agent connection state, disposing terminal transports, resetting streaming state, and triggering re-bootstrap.
Any cache keyed only by session ID, directory, or URL should be reviewed when runtime switching is involved. Use runtime keys when local and remote instances can share IDs or paths.
### Tests To Prefer
Use targeted transport/auth tests when changing request forwarding or URL auth: `packages/ui/src/lib/runtime-fetch.test.ts`, `packages/ui/src/lib/runtime-url.test.ts`, `packages/ui/src/lib/runtime-auth.test.ts`, `packages/web/server/lib/ui-auth/ui-auth.test.js`, `packages/vscode/webview/api/bridge.test.ts`, `packages/vscode/src/bridge-proxy-runtime.test.js`, `packages/web/server/opencode-proxy.test.js`, and `packages/web/server/lib/preview/proxy-runtime.test.js`.
Use runtime API tests near the implementation when adding or changing per-runtime behavior, for example web API tests under `packages/web/src/api/*.test.ts`, VS Code bridge tests under `packages/vscode/src/*test.js`, and UI wrapper tests under `packages/ui/src/lib/*test.ts`.
Run `bun run type-check` and `bun run lint` before finalizing code changes unless the user explicitly narrows validation.
## References
- SDK wrapper: `packages/ui/src/lib/opencode/client.ts`
- Runtime fetch/auth/url: `packages/ui/src/lib/runtime-fetch.ts`, `runtime-auth.ts`, `runtime-url.ts`
- Runtime API contract: `packages/ui/src/lib/api/types.ts`
- Web API composition: `packages/web/src/api/index.ts`, `packages/web/src/runtimeConfig.ts`
- VS Code bridge/proxy: `packages/vscode/webview/main.tsx`, `packages/vscode/webview/api/bridge.ts`, `packages/vscode/src/bridge-proxy-runtime.ts`
- Server proxy: `packages/web/server/lib/opencode/proxy.js`, `packages/web/server/lib/opencode/core-routes.js`
- UI auth and URL-token allowlists: `packages/web/server/lib/ui-auth/ui-auth.js`
- Preview proxy and rewritten browser subresources: `packages/web/server/lib/preview/proxy-runtime.js`