summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte1129
-rw-r--r--src/app/register-views.ts79
-rw-r--r--src/app/view-ports.ts54
-rw-r--r--src/app/views/CacheWarmingPane.svelte37
-rw-r--r--src/app/views/ChatPane.svelte91
-rw-r--r--src/app/views/CompactionPane.svelte48
-rw-r--r--src/app/views/ComposerPane.svelte35
-rw-r--r--src/app/views/ConcurrencyPane.svelte39
-rw-r--r--src/app/views/ExtensionsPane.svelte78
-rw-r--r--src/app/views/HeartbeatPane.svelte56
-rw-r--r--src/app/views/LspPane.svelte24
-rw-r--r--src/app/views/McpPane.svelte24
-rw-r--r--src/app/views/MessageQueuePane.svelte40
-rw-r--r--src/app/views/ModelPane.svelte131
-rw-r--r--src/app/views/NamedAgentsPane.svelte25
-rw-r--r--src/app/views/SettingsPane.svelte34
-rw-r--r--src/app/views/SystemPromptPane.svelte21
-rw-r--r--src/app/views/TabsPane.svelte24
-rw-r--r--src/app/views/TasksPane.svelte29
-rw-r--r--src/app/views/VisionPane.svelte45
-rw-r--r--src/features/layout/index.ts40
-rw-r--r--src/features/layout/logic/collapse.test.ts120
-rw-r--r--src/features/layout/logic/collapse.ts95
-rw-r--r--src/features/layout/logic/panels-edit.test.ts29
-rw-r--r--src/features/layout/logic/panels-edit.ts33
-rw-r--r--src/features/layout/logic/registry.test.ts80
-rw-r--r--src/features/layout/logic/registry.ts107
-rw-r--r--src/features/layout/logic/seed.test.ts99
-rw-r--r--src/features/layout/logic/seed.ts78
-rw-r--r--src/features/layout/logic/tree-query.ts60
-rw-r--r--src/features/layout/ui/FallbackTile.svelte13
-rw-r--r--src/features/layout/ui/TileLeaf.svelte23
-rw-r--r--src/features/layout/ui/TileNode.svelte37
-rw-r--r--src/features/layout/ui/TilePanels.svelte75
-rw-r--r--src/features/layout/ui/TileRoot.svelte39
-rw-r--r--src/features/layout/ui/TileRoot.test.ts228
-rw-r--r--src/features/layout/ui/TileSplit.svelte96
-rw-r--r--src/features/layout/ui/TileStack.svelte32
-rw-r--r--src/features/layout/ui/fixtures/FixtureA.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureB.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureC.svelte6
41 files changed, 2440 insertions, 905 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index f2d7e9c..b7eb783 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,919 +1,238 @@
-<script lang="ts">
- import type { ImageInput } from "@dispatch/transport-contract";
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import { tick } from "svelte";
- import Table from "../components/Table.svelte";
- import {
- CacheWarmingView,
- manifest as cacheWarmingManifest,
- type WarmFeedback,
- } from "../features/cache-warming";
- import {
- ChatView,
- CompactionView,
- Composer,
- manifest as chatManifest,
- ModelSelector,
- ReasoningEffortSelector,
- type CompactNowResult,
- type ComposerStatus,
- type SaveCompactPercentResult,
- type ThinkingSelection,
- type ThinkingSelectionSaveResult,
- } from "../features/chat";
- import { manifest as conversationCacheManifest } from "../features/conversation-cache";
- import { manifest as markdownManifest } from "../features/markdown";
- import {
- McpStatusView,
- manifest as mcpManifest,
- type McpStatusResult,
- } from "../features/mcp";
- import {
- ChatLimitField,
- manifest as settingsManifest,
- type ChatLimitSaveResult,
- } from "../features/settings";
- import {
- createSmartScrollController,
- manifest as smartScrollManifest,
- ScrollToBottom,
- } from "../features/smart-scroll";
- import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
- import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
- import { parseTodoPayload } from "../features/surface-host/logic/todo";
- import TodoList from "../features/surface-host/ui/TodoList.svelte";
- import { manifest as tabsManifest, TabList } from "../features/tabs";
- import { manifest as viewsManifest, ViewSidebar } from "../features/views";
- import {
- manifest as namedAgentsManifest,
- NamedAgentsView,
- type NamedAgentInput,
- type NamedAgentSelectionSnapshot,
- } from "../features/named-agents";
- import {
- CwdField,
- type CwdSaveResult,
- LspStatusView,
- type LspStatusResult,
- manifest as cwdLspManifest,
- } from "../features/cwd-lsp";
- import {
- ComputerField,
- manifest as computerManifest,
- type ComputerSaveResult,
- type ComputerStatusResult,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
- type TestComputerResult,
- } from "../features/computer";
- import {
- HeartbeatView,
- manifest as heartbeatManifest,
- RunModal,
- type HeartbeatConfigResult,
- type HeartbeatNextRunResult,
- type HeartbeatRunView,
- type HeartbeatRunsResult,
- type HeartbeatStopResult,
- } from "../features/heartbeat";
- import {
- ConcurrencyView,
- manifest as concurrencyManifest,
- type DeleteConcurrencyLimit,
- type LoadConcurrencyLimits,
- type LoadConcurrencyStatus,
- type SaveConcurrencyCooldown,
- type SaveConcurrencyLimit,
- } from "../features/concurrency";
- import type { ChatStore } from "../features/chat";
- import {
- SystemPromptBuilder,
- type LoadSystemPrompt as LoadSystemPromptAlias,
- type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
- type SaveSystemPrompt as SaveSystemPromptAlias,
- manifest as systemPromptManifest,
- } from "../features/system-prompt";
- import {
- VisionSettingsView,
- manifest as visionManifest,
- type LoadVisionSettingsResult,
- type SaveVisionSettingsResult,
- type VisionSettingsPatch,
- } from "../features/vision";
- import type { AppStore } from "./store.svelte";
- import ErrorModal from "./ErrorModal.svelte";
- import { createLocalStore } from "../adapters/local-storage";
- import { untrack } from "svelte";
-
- let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
-
- // The backend's conversation-scoped cache-warming surface. Referenced by id at
- // the composition root (sanctioned discovery-by-id) to give it a dedicated view
- // and keep it out of the generic Extensions surface list — SurfaceView itself
- // stays fully generic (it never switches on a surface id).
- const CACHE_WARMING_ID = "cache-warming";
- // The message-queue extension's per-conversation surface (steering). Pulled
- // out of the generic Extensions list and rendered as a compact panel above the
- // composer — pending steering messages are tied to the chat, not the sidebar.
- const MESSAGE_QUEUE_ID = "message-queue";
- // The `todo` extension's per-conversation task list surface (model-maintained).
- const TODO_ID = "todo";
-
- // The view kinds offered in the sidebar's dropdown. Generic data — the
- // `viewContent` snippet below maps each kind id to its renderer.
- const viewKinds = [
- { id: "tabs", label: "Tabs" },
- { id: "model", label: "Model" },
- { id: "named-agents", label: "Named Agents" },
- { id: "lsp", label: "Language Servers" },
- { id: "mcp", label: "MCP Servers" },
- { id: "extensions", label: "Extensions" },
- { id: "cache-warming", label: "Cache Warming" },
- { id: "tasks", label: "Tasks" },
- { id: "compaction", label: "Compaction" },
- { id: "vision", label: "Vision" },
- { id: "heartbeat", label: "Heartbeat" },
- { id: "concurrency", label: "Concurrency" },
- { id: "system-prompt", label: "System Prompt" },
- { id: "settings", label: "Settings" },
- ] as const;
-
- // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the
- // Model view, so a fresh user sees their conversations + model controls.
- const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"];
- const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
- storage: untrack(() => store.storage),
- });
- const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
-
- function handleSidebarChange(kinds: readonly (string | null)[]): void {
- sidebarStore.save(kinds.filter((k): k is string => k !== null));
- }
-
- // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
- // feature's public `manifest` export so it can't drift from what's actually
- // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
- // backend-owned list.) FE features are internal units of this single repo, so
- // there is no per-module version — they all share dispatch-web's version.
- const MODULE_COLUMNS = ["Module", "Description"] as const;
- const loadedModules: readonly (readonly [string, string])[] = [
- chatManifest,
- tabsManifest,
- surfaceHostManifest,
- viewsManifest,
- conversationCacheManifest,
- markdownManifest,
- cacheWarmingManifest,
- cwdLspManifest,
- mcpManifest,
- computerManifest,
- smartScrollManifest,
- settingsManifest,
- systemPromptManifest,
- heartbeatManifest,
- concurrencyManifest,
- visionManifest,
- namedAgentsManifest,
- ].map((m) => [m.name, m.description] as const);
-
- // Smart-scroll: keep the transcript pinned to the bottom while it streams,
- // unless the reader has scrolled up (then show a "scroll to bottom" button).
- // One controller owns the chat scroll region; effects below feed it the edges.
- const smartScroll = createSmartScrollController();
- let transcriptEl = $state<HTMLElement | undefined>();
- let transcriptContentEl = $state<HTMLElement | undefined>();
-
- // Chat-limit unload gate: old chunks may be unloaded only while the reader is
- // stuck to the bottom. While stuck, a trim removes content far ABOVE the
- // viewport and the controller re-pins to the bottom — no visible jump; while
- // reading history, trimming is deferred instead of yanking the page (the old
- // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
- $effect(() => {
- store.attachUnloadGate(() => smartScroll.isAtBottom());
- });
-
- // "Show earlier messages": page older history back in, preserving the reader's
- // viewport position — prepended content grows scrollHeight, so shift scrollTop
- // by the growth (the manual analogue of CSS scroll anchoring, which not every
- // engine applies here).
- async function handleShowEarlier(): Promise<void> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- await store.activeChat.showEarlier();
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- }
-
- // Attach/detach the controller to the live scroll element + content (disposed on
- // unmount). The content element is observed (ResizeObserver) so the view follows
- // height changes that aren't a transcript append.
- $effect(() => {
- if (!transcriptEl) return;
- return smartScroll.attach(transcriptEl, transcriptContentEl);
- });
-
- // New transcript content streamed in (or messages loaded) → follow the bottom
- // while stuck. Reads `chunks.length` so the effect re-runs on every append.
- $effect(() => {
- void store.activeChat.chunks.length;
- smartScroll.contentChanged();
- });
-
- // The message-queue surface spec + whether it currently has pending messages
- // (steering). Rendered as a compact panel above the composer only when non-empty.
- const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
- const hasQueuedMessages = $derived.by(() => {
- const spec = messageQueueSpec;
- if (spec === null) return false;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
- if (field === undefined || field.kind !== "custom") return false;
- const data = parseMessageQueuePayload(field.payload);
- return data !== null && data.messages.length > 0;
- });
-
- // The todo surface spec + its parsed task list (model-maintained, read-only).
- const todoSpec = $derived(store.surface(TODO_ID));
- const todoData = $derived.by(() => {
- const spec = todoSpec;
- if (spec === null) return null;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
- if (field === undefined || field.kind !== "custom") return null;
- return parseTodoPayload(field.payload);
- });
-
- // Top-bar title: the active tab's title, or "New Tab" when no tab is active
- // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
- // tab exists). Pure-derived from the (workspace-filtered) tab set + the
- // active id; reflects whichever tab is selected in the sidebar's Tabs view.
- const NEW_TAB_TITLE = "New Tab";
- const topBarTitle = $derived.by(() => {
- const id = store.activeConversationId;
- if (id === null) return NEW_TAB_TITLE;
- const tab = store.tabs.find((t) => t.conversationId === id);
- return tab?.title ?? NEW_TAB_TITLE;
- });
-
- // The composer status-bar status. Priority: error > queued > running > idle.
- // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13)
- // wins over `running` so the corner shows a ring, not dots, during the wait.
- // `turn-start` fires before the slot is granted, so `generating` is already
- // true while `conversationStatus === "queued"`; the explicit queued check is
- // what distinguishes the two.
- const composerStatus = $derived.by<ComposerStatus>(() => {
- if (store.activeChat.error) return "error";
- const id = store.activeConversationId;
- if (id !== null && store.conversationStatus(id) === "queued") return "queued";
- return store.activeChat.generating ? "running" : "idle";
- });
-
- // Conversation/tab switch → snap to the bottom of the new transcript.
- $effect(() => {
- void store.activeConversationId;
- smartScroll.reset();
- });
-
- // Right sidebar: persisted open/closed state. Defaults to open on wide
- // screens (first visit), then remembers the user's toggle thereafter.
- const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
- const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
- storage: untrack(() => store.storage),
- });
- const storedSidebarOpen = sidebarOpenStore.load();
- let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true));
- let systemPromptModalOpen = $state(false);
- // The heartbeat run currently open in the fullscreen run-chat modal (null =
- // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
- let heartbeatRun = $state<HeartbeatRunView | null>(null);
-
- $effect(() => {
- sidebarOpenStore.save(sidebarOpen);
- });
-
- function handleInvoke(msg: InvokeMessage) {
- store.invoke(msg.surfaceId, msg.actionId, msg.payload);
- }
-
- function handleSend(text: string, images?: readonly ImageInput[]): void {
- store.send(text, images);
- }
-
- function handleQueue(text: string) {
- store.queueMessage(text);
- }
-
- function handleCancelQueuedMessage(messageId: string) {
- store.cancelQueuedMessage(messageId);
- }
-
- function handleStop() {
- store.stopGeneration();
- }
-
- function handleSelectModel(model: string) {
- store.selectModel(model);
- }
-
- // A Named Agent selected in the model selector: resolve it to its raw model
- // AND record the snapshot on the active conversation (plan §6/§7). The store
- // persists the raw model to `/conversations/:id/model` + retains the snapshot
- // on the tab.
- function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot) {
- store.selectModel(snapshot.model, snapshot);
- }
-
- // Adapt the store's Named Agent CRUD to the named-agents feature's ports.
- const namedAgentPorts = {
- create: (input: NamedAgentInput) => store.createNamedAgent(input),
- update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input),
- remove: (id: string) => store.deleteNamedAgent(id),
- };
-
- // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
- async function warmNow(): Promise<WarmFeedback | null> {
- const result = await store.warmNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- cachePct: result.response.cachePct,
- expectedCacheRate: result.response.expectedCacheRate,
- }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's reasoning-effort + thinking results to the chat
- // feature's combined selector port. The selector sends ONE selection ("off"
- // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is
- // a SEPARATE signal from the effort level: it persists `thinking: false`
- // (the umans route maps that to `reasoning_effort: "none"`), leaving the
- // effort level untouched so an off→on toggle restores it. A level ensures
- // thinking is ON (the level is meaningless while thinking is off) then sets
- // the effort level.
- async function saveThinkingSelection(
- selection: ThinkingSelection,
- ): Promise<ThinkingSelectionSaveResult | null> {
- if (selection === "off") {
- const result = await store.setThinking(false);
- if (result === null) return null;
- return result.ok
- ? { ok: true, selection: "off" }
- : { ok: false, error: result.error };
- }
- // A level: enable thinking first if it is currently off, then set the level.
- if (store.thinking === false) {
- const on = await store.setThinking(true);
- if (on !== null && !on.ok) {
- return { ok: false, error: on.error };
- }
- }
- const result = await store.setReasoningEffort(selection);
- if (result === null) return null;
- return result.ok
- ? { ok: true, selection: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
+<script module lang="ts">
+ import { registerBuiltinViews } from "./register-views";
- // Adapt the store's compact result to the compaction view's port.
- async function compactNow(): Promise<CompactNowResult | null> {
- const result = await store.compactNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- messagesSummarized: result.response.messagesSummarized,
- messagesKept: result.response.messagesKept,
- }
- : { ok: false, error: result.error };
- }
-
- async function saveCompactPercent(
- percent: number,
- ): Promise<SaveCompactPercentResult | null> {
- const result = await store.setCompactPercent(percent);
- if (result === null) return null;
- return result.ok
- ? { ok: true, percent: result.percent }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's global vision-settings API to the vision feature's ports.
- async function loadVisionSettings(): Promise<LoadVisionSettingsResult> {
- // The store seeds `visionSettings` on boot; a refresh keeps it current.
- await store.refreshVisionSettings();
- const settings = store.visionSettings;
- if (settings === null) {
- return { ok: false, error: "Vision settings not available." };
- }
- return { ok: true, settings };
- }
-
- async function saveVisionSettings(
- patch: VisionSettingsPatch,
- ): Promise<SaveVisionSettingsResult> {
- const result = await store.setVisionSettings(patch);
- if (result === null) return { ok: false, error: "Vision settings not available." };
- return result.ok
- ? { ok: true, settings: result.settings }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's chat-limit result to the settings feature's port. On a
- // raise the active chat refills (prepends older history); preserve the
- // reader's viewport over the prepend (the manual analogue of CSS scroll
- // anchoring), exactly like `handleShowEarlier`.
- async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- const result = await store.setChatLimit(value);
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- return result.ok
- ? { ok: true, chatLimit: result.chatLimit }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
- async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
- const result = await store.setCwd(cwd);
- if (result === null) return null;
- return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
- }
-
- async function loadLspStatus(): Promise<LspStatusResult | null> {
- const result = await store.lspStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's computer results to the computer feature's ports.
- async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
- const result = await store.setComputer(computerId);
- if (result === null) return null;
- return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
- }
-
- const loadComputerStatus: LoadComputerStatus = async (
- alias: string,
- ): Promise<ComputerStatusResult | null> => {
- const result = await store.computerStatus(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
- };
-
- const testComputer: TestComputer = async (
- alias: string,
- ): Promise<TestComputerResult | null> => {
- const result = await store.testComputer(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
- };
-
- async function loadMcpStatus(): Promise<McpStatusResult | null> {
- const result = await store.mcpStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's system prompt results to the system-prompt feature's ports.
- const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
-
- const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
- store.loadSystemPromptVariables();
-
- const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template);
-
- // Adapt the store's heartbeat results to the heartbeat feature's ports. The
- // store returns the feature's result types directly (the API is a plain REST
- // surface, not a transport-contract type), so the adapter is a thin passthrough
- // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md
- // "contracts are the cross-unit surface").
- async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
- return store.heartbeatConfig();
- }
-
- async function saveHeartbeatConfig(
- patch: Parameters<typeof store.setHeartbeatConfig>[0],
- ): Promise<HeartbeatConfigResult> {
- return store.setHeartbeatConfig(patch);
- }
-
- async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
- return store.heartbeatRuns();
- }
-
- async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
- return store.stopHeartbeatRun(runId);
- }
-
- async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
- return store.heartbeatNextRun();
- }
-
- // Run-chat modal: open a live watch on the run's conversation (the store owns
- // the ChatStore + the `chat.subscribe` stream), and tear it down on close.
- function openRunChat(conversationId: string): ChatStore {
- return store.watchConversation(conversationId);
- }
- function closeRunChat(conversationId: string): void {
- store.unwatchConversation(conversationId);
- }
-
- // Adapt the store's concurrency results to the feature's ports. The store
- // returns the feature's result types directly (the API is a plain REST surface
- // under /concurrency, not a workspace/conversation-scoped one), so the adapter
- // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts
- // are the cross-unit surface").
- const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
- const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
- store.setConcurrencyLimit(providerId, limit);
- const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
- store.deleteConcurrencyLimit(providerId);
- const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
- const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
- store.setConcurrencyCooldown(providerId, cooldownMs);
+ // Register every built-in view ONCE per app load, BEFORE any tile renders.
+ // The composition root stays the only place store ports are wired: the
+ // wrappers receive them through `ports` (see `view-ports.ts`).
+ registerBuiltinViews();
</script>
-<main class="relative flex h-screen overflow-hidden">
- <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
- (below), so opening it shrinks this ENTIRE column. -->
- <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
- the top row now shows the active tab's title on the left (or "New Tab"
- for an unstarted draft), with the build version + sidebar toggle on
- the right. -->
- <div class="flex items-center justify-between gap-2 px-2 py-2">
- <span
- class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
- data-testid="top-bar-title"
- title={topBarTitle}
- aria-label="Active conversation title"
- >
- {topBarTitle}
- </span>
- <a
- href="/"
- class="btn btn-ghost btn-sm shrink-0 px-2"
- aria-label="Back to dashboard"
- title="Back to dashboard"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/");
- }}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
- />
- </svg>
- </a>
- <span
- class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
- title="Build version (git short hash)"
- >
- build: {__APP_VERSION__}
- </span>
- <button
- class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
- aria-expanded={sidebarOpen}
- onclick={() => (sidebarOpen = !sidebarOpen)}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- {#if sidebarOpen}
- <!-- Sidebar open → chevrons point right -->
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
- />
- {:else}
- <!-- Sidebar closed → chevrons point left -->
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
- />
- {/if}
- </svg>
- </button>
- </div>
-
- {#if store.lastError}
- <div role="alert" class="alert alert-error mx-4 mt-2">
- <strong>Error:</strong>
- {store.lastError.message}
- </div>
- {/if}
-
- {#if store.activeChat.error}
- <div role="alert" class="alert alert-warning mx-4 mt-2">
- <strong>Chat error:</strong>
- {store.activeChat.error}
- </div>
- {/if}
-
- <div class="relative min-h-0 min-w-0 flex-1 pr-4">
- <div bind:this={transcriptEl} class="h-full overflow-y-auto">
- <div bind:this={transcriptContentEl}>
- {#key store.activeConversationId}
- <ChatView
- chunks={store.activeChat.chunks}
- turnMetrics={store.activeChat.turnMetrics}
- hasEarlier={store.activeChat.hasEarlier}
- onShowEarlier={handleShowEarlier}
- thinkingKeyBase={store.activeChat.thinkingKeyBase}
- providerRetry={store.activeChat.providerRetry}
- apiBaseUrl={store.httpBase}
- />
- {/key}
- </div>
- </div>
- {#if store.activeChat.chunks.length === 0}
- <div
- class="pointer-events-none absolute inset-0 flex items-center justify-center"
- aria-hidden="true"
- >
- <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
- </div>
- {/if}
- <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
- </div>
-
- {#if hasQueuedMessages && messageQueueSpec !== null}
- <!-- Pending steering messages (the message-queue surface). Rendered via
- the generic SurfaceView (dispatches on rendererId, never surface id);
- only shown when the queue is non-empty — an idle queue is hidden. -->
- <div class="px-4 pt-2">
- <SurfaceView
- spec={messageQueueSpec}
- onInvoke={handleInvoke}
- onCancelQueuedMessage={handleCancelQueuedMessage}
- />
- </div>
- {/if}
-
- <Composer
- onSend={handleSend}
- onQueue={handleQueue}
- onStop={handleStop}
- contextSize={store.activeChat.currentContextSize}
- contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
- status={composerStatus}
- />
- </div>
-
- <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
- opening it shrinks the whole left column (push). Below `lg` it overlays
- (`max-lg:absolute`, full height) with a backdrop. -->
- <aside
- class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
- </div>
- </aside>
+<script lang="ts">
+ import type { LayoutTree } from "@dispatch/layout-contract";
+ import { untrack } from "svelte";
+ import { createLocalStore } from "../adapters/local-storage";
+ import {
+ createCollapseStore,
+ firstCollapsibleNodeId,
+ firstPanelsNode,
+ listPanelViews,
+ TileRoot,
+ } from "../features/layout";
+ import { RunModal, type HeartbeatRunView } from "../features/heartbeat";
+ import { SystemPromptBuilder } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import { seedLayout } from "../features/layout";
+ import type { AppViewPorts, ChatViewportBridge } from "./view-ports";
+ import ErrorModal from "./ErrorModal.svelte";
+
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
+
+ // Modal chrome — app shell state, OUTSIDE the tree (layout-00 decision 2).
+ let systemPromptModalOpen = $state(false);
+ // The heartbeat run currently open in the fullscreen run-chat modal (null =
+ // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
+ let heartbeatRun = $state<HeartbeatRunView | null>(null);
+
+ // --- The layout tree: seeded from DEFAULT_LAYOUT (the compatibility tree
+ // that reproduces today's layout) with the sidebar selection overlaid from
+ // localStorage — today's exact boot behavior (unknown/removed kinds dropped;
+ // nothing valid stored → the tabs+model defaults).
+ const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
+ storage: untrack(() => store.storage),
+ });
+ let tree = $state<LayoutTree>(
+ seedLayout(sidebarStore.load(), listPanelViews().map((v) => v.id)),
+ );
+
+ // Tile edits flow up from the panels; the OWNER decides persistence. The
+ // panels selection is written back to the SAME key in the SAME format
+ // (`dispatch.sidebar.views` — a JSON array of view ids) so the storage stays
+ // byte-compatible with today (plan 02 migrates it).
+ function handleTreeChange(next: LayoutTree): void {
+ tree = next;
+ const panels = firstPanelsNode(next);
+ if (panels !== null) {
+ sidebarStore.save(panels.node.panels.map((p) => p.view));
+ }
+ }
+
+ // --- Sidebar collapse: renderer chrome state (per-node-id open/closed),
+ // persisted with the SAME key/semantics as today (`dispatch.sidebar.open`, a
+ // bare boolean, default open on wide screens). The toggle drives the tree's
+ // single collapsible node ("side" on the default tree; generically the first
+ // collapsible split child).
+ const collapse = createCollapseStore({
+ storage: untrack(() => store.storage),
+ viewportWidth: typeof window !== "undefined" ? window.innerWidth : undefined,
+ });
+ const collapseState = $derived($collapse);
+ const collapseKey = $derived(firstCollapsibleNodeId(tree));
+ const sidebarOpen = $derived(
+ collapseKey !== null ? (collapseState.openByNode[collapseKey] ?? collapseState.defaultOpen) : false,
+ );
+
+ function toggleSidebar(): void {
+ if (collapseKey !== null) collapse.toggle(collapseKey);
+ }
+
+ // --- The port bag every view wrapper receives. This is the ONE place the
+ // store's ports are wired (AGENTS.md: composition root). `store` is read
+ // through a getter so a swapped store prop stays reactive through it.
+ const chatViewport: ChatViewportBridge = { el: null };
+ const viewPorts: AppViewPorts = {
+ get store() {
+ return store;
+ },
+ chatViewport,
+ openSystemPromptBuilder: () => (systemPromptModalOpen = true),
+ openHeartbeatRun: (run) => (heartbeatRun = run),
+ };
+
+ // Top-bar title: the active tab's title, or "New Tab" when no tab is active
+ // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
+ // tab exists). Pure-derived from the (workspace-filtered) tab set + the
+ // active id; reflects whichever tab is selected in the sidebar's Tabs view.
+ const NEW_TAB_TITLE = "New Tab";
+ const topBarTitle = $derived.by(() => {
+ const id = store.activeConversationId;
+ if (id === null) return NEW_TAB_TITLE;
+ const tab = store.tabs.find((t) => t.conversationId === id);
+ return tab?.title ?? NEW_TAB_TITLE;
+ });
+
+ function handleInvokeHome(): void {
+ onNavigate("/");
+ }
+</script>
- <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
- {#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 z-20 bg-black/30 lg:hidden"
- role="button"
- tabindex="0"
- aria-label="Close sidebar"
- onclick={() => (sidebarOpen = false)}
- onkeydown={(e) => {
- if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
- }}
- ></div>
- {/if}
+<main class="relative flex h-screen flex-col overflow-hidden pt-[5px]">
+ <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
+ the top row now shows the active tab's title on the left (or "New Tab"
+ for an unstarted draft), with the build version + sidebar toggle on
+ the right. CHROME — stays outside the tree (layout-00 decision 2). -->
+ <div class="flex items-center justify-between gap-2 px-2 py-2">
+ <span
+ class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ handleInvokeHome();
+ }}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
+ />
+ </svg>
+ </a>
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
+ title="Build version (git short hash)"
+ >
+ build: {__APP_VERSION__}
+ </span>
+ {#if collapseKey !== null}
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
+ aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
+ aria-expanded={sidebarOpen}
+ onclick={toggleSidebar}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ {#if sidebarOpen}
+ <!-- Sidebar open → chevrons point right -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
+ />
+ {:else}
+ <!-- Sidebar closed → chevrons point left -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
+ />
+ {/if}
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ {#if store.lastError}
+ <div role="alert" class="alert alert-error mx-4 mt-2">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
+ <strong>Chat error:</strong>
+ {store.activeChat.error}
+ </div>
+ {/if}
+
+ <!-- THE TREE: the tile renderer owns the content region. Tile edits are
+ applied here and persisted to dispatch.sidebar.views (above). -->
+ <div class="min-h-0 min-w-0 flex-1">
+ <TileRoot {tree} {collapse} ports={viewPorts} onTreeChange={handleTreeChange} />
+ </div>
</main>
{#if store.fatalError}
- <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
+ <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
{/if}
{#if systemPromptModalOpen}
- <SystemPromptBuilder
- loadPrompt={loadSystemPromptPrompt}
- savePrompt={saveSystemPromptPrompt}
- loadVariables={loadSystemPromptVariablesPrompt}
- onClose={() => (systemPromptModalOpen = false)}
- />
+ <SystemPromptBuilder
+ loadPrompt={() => store.loadSystemPrompt()}
+ savePrompt={(template) => store.setSystemPrompt(template)}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
{/if}
{#if heartbeatRun !== null}
- <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
- fresh watch store lifecycle per run. The modal owns the live watch
- (openChat/closeChat) and the Stop button. -->
- {#key heartbeatRun.id}
- <RunModal
- run={heartbeatRun}
- openChat={openRunChat}
- closeChat={closeRunChat}
- stopRun={stopHeartbeatRun}
- onClose={() => (heartbeatRun = null)}
- apiBaseUrl={store.httpBase}
- />
- {/key}
+ <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
+ fresh watch store lifecycle per run. The modal owns the live watch
+ (openChat/closeChat) and the Stop button. -->
+ {#key heartbeatRun.id}
+ <RunModal
+ run={heartbeatRun}
+ openChat={(conversationId) => store.watchConversation(conversationId)}
+ closeChat={(conversationId) => store.unwatchConversation(conversationId)}
+ stopRun={(runId) => store.stopHeartbeatRun(runId)}
+ onClose={() => (heartbeatRun = null)}
+ apiBaseUrl={store.httpBase}
+ />
+ {/key}
{/if}
-
-{#snippet viewContent(kind: string)}
- {#if kind === "tabs"}
- <!-- The conversation tab list (moved out of the top bar into the sidebar).
- Re-mount per workspace so the filtered tab set + scroll reset cleanly
- on a workspace switch. -->
- {#key store.activeWorkspaceId}
- <TabList
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- statusFor={(id) => store.conversationStatus(id)}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- onRename={(id, title) => store.renameTab(id, title)}
- />
- {/key}
- {:else if kind === "model"}
- <div class="flex flex-col gap-3">
- <ModelSelector
- models={store.models}
- selected={store.activeModel}
- onSelect={handleSelectModel}
- modelInfo={store.modelInfo}
- namedAgents={store.namedAgents}
- namedAgentSnapshot={store.namedAgentSnapshot}
- onSelectNamedAgent={handleSelectNamedAgent}
- zeroProviders={store.enabledProviderCount === 0}
- />
- <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
- re-mount per conversation — incl. switching between drafts — and can't
- bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
- {#key store.currentConversationId}
- <ReasoningEffortSelector
- persistedEffort={store.reasoningEffort}
- persistedThinking={store.thinking}
- save={saveThinkingSelection}
- />
- <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
- <ComputerField
- computerId={store.computerId}
- canEdit={true}
- computers={store.computers}
- save={saveComputer}
- loadStatus={loadComputerStatus}
- test={testComputer}
- />
- {/key}
- </div>
- {:else if kind === "named-agents"}
- <!-- Named Agents: a global, collapsible sidebar view of curated presets.
- The store owns the list (authoritative for the model selector + agent
- tooling); the view drives CRUD via injected ports (auto-save). -->
- <NamedAgentsView
- agents={store.namedAgents}
- models={store.models}
- ports={namedAgentPorts}
- />
- {:else if kind === "lsp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
- {/key}
- {:else if kind === "mcp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
- {/key}
- {:else if kind === "extensions"}
- <section>
- <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
- <Table columns={MODULE_COLUMNS} rows={loadedModules} />
- </section>
- <section class="mt-4 flex flex-col gap-3">
- <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
- <SurfaceView {spec} onInvoke={handleInvoke} />
- {/each}
- </section>
- {:else if kind === "cache-warming"}
- <!-- Re-mount per conversation (like ChatView) so the view's local warming
- history / manual-warm feedback can't bleed across tabs. -->
- {#key store.activeConversationId}
- <CacheWarmingView
- spec={store.surface(CACHE_WARMING_ID)}
- canWarm={store.activeConversationId !== null}
- onInvoke={handleInvoke}
- {warmNow}
- />
- {/key}
- {:else if kind === "tasks"}
- <!-- Re-mount per conversation so the task list is isolated per conversation.
- TodoList always reserves its fixed 60vh height (empty or full). -->
- {#key store.activeConversationId}
- <TodoList payload={todoData} />
- {/key}
- {:else if kind === "compaction"}
- <!-- Message compaction is per-conversation (keyed so the percent + feedback
- can't bleed across tabs). -->
- {#key store.currentConversationId}
- <CompactionView
- percent={store.compactPercent}
- canCompact={store.activeConversationId !== null}
- {compactNow}
- savePercent={saveCompactPercent}
- />
- {/key}
- {:else if kind === "vision"}
- <!-- Global vision settings (image-compaction limit + model). Not
- conversation-scoped (no {#key}), so they stay mounted across tab switches. -->
- <VisionSettingsView
- models={store.models}
- modelInfo={store.modelInfo}
- load={loadVisionSettings}
- save={saveVisionSettings}
- />
- {:else if kind === "system-prompt"}
- <!-- Global system prompt template. Opens a full-page modal editor (half
- template / half variable palette). Not conversation-scoped (no {#key}). -->
- <div class="flex flex-col gap-2">
- <p class="text-xs opacity-60">
- Edit the global system prompt template with variable placeholders. Opens a full-page editor.
- </p>
- <button
- type="button"
- class="btn btn-primary btn-sm"
- onclick={() => (systemPromptModalOpen = true)}
- >
- Open builder
- </button>
- </div>
- {:else if kind === "settings"}
- <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
- global), so the field stays mounted across tab switches. -->
- <div class="flex flex-col gap-3">
- <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
- </div>
- {:else if kind === "heartbeat"}
- <!-- Workspace-scoped autonomous-agent heartbeat (config + run history).
- Not conversation-scoped (no {#key}); the config + runs are per-workspace. -->
- <HeartbeatView
- models={store.models}
- loadConfig={loadHeartbeatConfig}
- saveConfig={saveHeartbeatConfig}
- loadRuns={loadHeartbeatRuns}
- stopRun={stopHeartbeatRun}
- loadVariables={loadSystemPromptVariablesPrompt}
- loadDefaultPrompt={loadSystemPromptPrompt}
- loadNextRun={loadHeartbeatNextRun}
- onOpenRun={(run) => (heartbeatRun = run)}
- />
- {:else if kind === "concurrency"}
- <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or
- conversation-scoped), so the panel stays mounted across tab switches. -->
- <ConcurrencyView
- models={store.models}
- loadLimits={loadConcurrencyLimits}
- saveLimit={saveConcurrencyLimit}
- deleteLimit={deleteConcurrencyLimit}
- loadStatus={loadConcurrencyStatus}
- saveCooldown={saveConcurrencyCooldown}
- />
- {/if}
-{/snippet}
diff --git a/src/app/register-views.ts b/src/app/register-views.ts
new file mode 100644
index 0000000..5b462fe
--- /dev/null
+++ b/src/app/register-views.ts
@@ -0,0 +1,79 @@
+/**
+ * Built-in view registration — the composition root's view vocabulary.
+ *
+ * One thin wrapper component per existing view (src/app/views/*.svelte), each
+ * closing over exactly the props/ports App.svelte's `viewContent` snippet used
+ * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the
+ * wrappers). Registry ids match the old `viewKinds` ids exactly: no
+ * user-facing renaming, no persistence-key changes.
+ *
+ * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they
+ * register with `panelEligible: false` so they never appear in the panel
+ * dropdowns. All 14 sidebar views keep the eligible default.
+ *
+ * Imported ONCE by App.svelte's module script — before any tile renders.
+ */
+
+import type { Component } from "svelte";
+import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout";
+import type { AppViewPorts } from "./view-ports";
+import CacheWarmingPane from "./views/CacheWarmingPane.svelte";
+import ChatPane from "./views/ChatPane.svelte";
+import CompactionPane from "./views/CompactionPane.svelte";
+import ComposerPane from "./views/ComposerPane.svelte";
+import ConcurrencyPane from "./views/ConcurrencyPane.svelte";
+import ExtensionsPane from "./views/ExtensionsPane.svelte";
+import HeartbeatPane from "./views/HeartbeatPane.svelte";
+import LspPane from "./views/LspPane.svelte";
+import McpPane from "./views/McpPane.svelte";
+import MessageQueuePane from "./views/MessageQueuePane.svelte";
+import ModelPane from "./views/ModelPane.svelte";
+import NamedAgentsPane from "./views/NamedAgentsPane.svelte";
+import SettingsPane from "./views/SettingsPane.svelte";
+import SystemPromptPane from "./views/SystemPromptPane.svelte";
+import TabsPane from "./views/TabsPane.svelte";
+import TasksPane from "./views/TasksPane.svelte";
+import VisionPane from "./views/VisionPane.svelte";
+
+/**
+ * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but
+ * the registry is a HETEROGENEOUS bag of components (generic `Component`), so
+ * the composition root bridges each wrapper's concrete props type to the
+ * registry's opaque one. ONE controlled cast at this seam (the renderer always
+ * passes the port bag; the wrappers re-narrow it) instead of `any` in the
+ * contract.
+ */
+type WrapperComponent = Component<{ ports: AppViewPorts }>;
+function asView(component: WrapperComponent): ViewComponent {
+ return component as unknown as ViewComponent;
+}
+
+export function registerBuiltinViews(): void {
+ const views: readonly ViewRegistration[] = [
+ // --- sidebar views (panel-eligible by default — the 14 old viewKinds)
+ { id: "tabs", label: "Tabs", component: asView(TabsPane) },
+ { id: "model", label: "Model", component: asView(ModelPane) },
+ { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) },
+ { id: "lsp", label: "Language Servers", component: asView(LspPane) },
+ { id: "mcp", label: "MCP Servers", component: asView(McpPane) },
+ { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) },
+ { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) },
+ { id: "tasks", label: "Tasks", component: asView(TasksPane) },
+ { id: "compaction", label: "Compaction", component: asView(CompactionPane) },
+ { id: "vision", label: "Vision", component: asView(VisionPane) },
+ { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) },
+ { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) },
+ { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) },
+ { id: "settings", label: "Settings", component: asView(SettingsPane) },
+ // --- content views (never in the panel dropdowns — decision 5)
+ { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false },
+ { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false },
+ {
+ id: "message-queue",
+ label: "Message Queue",
+ component: asView(MessageQueuePane),
+ panelEligible: false,
+ },
+ ];
+ for (const view of views) registerView(view);
+}
diff --git a/src/app/view-ports.ts b/src/app/view-ports.ts
new file mode 100644
index 0000000..577fe11
--- /dev/null
+++ b/src/app/view-ports.ts
@@ -0,0 +1,54 @@
+/**
+ * The port bag the composition root hands to every registered view wrapper.
+ *
+ * This is the ONLY shape of app state the wrappers may touch: the app store
+ * plus the two chrome ports that live outside the tree (the system-prompt
+ * builder modal and the heartbeat run modal). Keeping it here is what makes
+ * `register-views.ts` possible — the wrappers take `{ ports }` and close over
+ * exactly what `viewContent` used to pass in App.svelte.
+ */
+
+import { tick } from "svelte";
+import type { HeartbeatRunView } from "../features/heartbeat";
+import type { AppStore } from "./store.svelte";
+
+/**
+ * The chat transcript's live scroll element, shared with views that must
+ * preserve the reader's viewport across a prepend (today: the settings view's
+ * chat-limit save — the same mechanism as "show earlier"). The chat pane writes
+ * it on mount via an effect; nothing else may.
+ */
+export interface ChatViewportBridge {
+ el: HTMLElement | null;
+}
+
+export interface AppViewPorts {
+ readonly store: AppStore;
+ readonly chatViewport: ChatViewportBridge;
+ /** Chrome: open the system-prompt builder modal (app shell state, not a view). */
+ readonly openSystemPromptBuilder: () => void;
+ /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */
+ readonly openHeartbeatRun: (run: HeartbeatRunView) => void;
+}
+
+/**
+ * Run an async action that PREPENDS transcript content while preserving the
+ * reader's viewport position: prepended content grows scrollHeight, so shift
+ * scrollTop by the growth (the manual analogue of CSS scroll anchoring, which
+ * not every engine applies here). Used by "show earlier" AND the chat-limit
+ * save (which refills older history on a raise).
+ */
+export async function preserveViewportAcross<T>(
+ el: HTMLElement | null | undefined,
+ run: () => Promise<T>,
+): Promise<T> {
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await run();
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result;
+}
diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte
new file mode 100644
index 0000000..d1d9b43
--- /dev/null
+++ b/src/app/views/CacheWarmingPane.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The `cache-warming` view — the backend's conversation-scoped cache-warming
+ * surface with the WarmNow adapter. Re-mounts per conversation (like
+ * ChatView) so the view's local warming history / manual-warm feedback
+ * can't bleed across tabs (the {#key} moved inside the wrapper).
+ */
+ import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming";
+ import type { AppViewPorts } from "../view-ports";
+
+ const CACHE_WARMING_ID = "cache-warming";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
+ async function warmNow(): Promise<WarmFeedback | null> {
+ const result = await store.warmNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ cachePct: result.response.cachePct,
+ expectedCacheRate: result.response.expectedCacheRate,
+ }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.activeConversationId}
+ <CacheWarmingView
+ spec={store.surface(CACHE_WARMING_ID)}
+ canWarm={store.activeConversationId !== null}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ {warmNow}
+ />
+{/key}
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte
new file mode 100644
index 0000000..1c04b9f
--- /dev/null
+++ b/src/app/views/ChatPane.svelte
@@ -0,0 +1,91 @@
+<script lang="ts">
+ /**
+ * The `chat` view — the transcript region that moved out of App.svelte with
+ * ALL of its coupling: the smart-scroll controller (created here, reset on
+ * conversation switch like today's reset effect), the unload-gate attach
+ * effect, the follow-bottom effect, "show earlier" with viewport
+ * preservation, the ScrollToBottom affordance, and the empty-state
+ * watermark. Keyed on the active conversation exactly like today's ChatView.
+ */
+ import { ChatView } from "../../features/chat";
+ import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
+ import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // One controller owns the chat scroll region; the effects below feed it the
+ // edges (identical to the App.svelte wiring this moved out of).
+ const smartScroll = createSmartScrollController();
+ let transcriptEl = $state<HTMLElement | undefined>();
+ let transcriptContentEl = $state<HTMLElement | undefined>();
+
+ // The settings view's chat-limit save preserves the reader's viewport over
+ // the history refill — share the live scroll element through the bridge.
+ $effect(() => {
+ ports.chatViewport.el = transcriptEl ?? null;
+ });
+
+ // Chat-limit unload gate: old chunks may be unloaded only while the reader is
+ // stuck to the bottom. While stuck, a trim removes content far ABOVE the
+ // viewport and the controller re-pins to the bottom — no visible jump; while
+ // reading history, trimming is deferred instead of yanking the page (the old
+ // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
+ $effect(() => {
+ store.attachUnloadGate(() => smartScroll.isAtBottom());
+ });
+
+ // "Show earlier messages": page older history back in, preserving the reader's
+ // viewport position.
+ async function handleShowEarlier(): Promise<void> {
+ await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier());
+ }
+
+ // Attach/detach the controller to the live scroll element + content (disposed
+ // on unmount). The content element is observed (ResizeObserver) so the view
+ // follows height changes that aren't a transcript append.
+ $effect(() => {
+ if (!transcriptEl) return;
+ return smartScroll.attach(transcriptEl, transcriptContentEl);
+ });
+
+ // New transcript content streamed in (or messages loaded) → follow the bottom
+ // while stuck. Reads `chunks.length` so the effect re-runs on every append.
+ $effect(() => {
+ void store.activeChat.chunks.length;
+ smartScroll.contentChanged();
+ });
+
+ // Conversation/tab switch → snap to the bottom of the new transcript.
+ $effect(() => {
+ void store.activeConversationId;
+ smartScroll.reset();
+ });
+</script>
+
+<div class="relative h-full pr-4">
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl}>
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ apiBaseUrl={store.httpBase}
+ />
+ {/key}
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
+ </div>
+ {/if}
+ <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
+</div>
diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte
new file mode 100644
index 0000000..ff31f0b
--- /dev/null
+++ b/src/app/views/CompactionPane.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ /**
+ * The `compaction` view — message compaction with the store adapters.
+ * Per-conversation (keyed so the percent + feedback can't bleed across tabs;
+ * the {#key} moved inside the wrapper).
+ */
+ import {
+ CompactionView,
+ type CompactNowResult,
+ type SaveCompactPercentResult,
+ } from "../../features/chat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's compact result to the compaction view's port.
+ async function compactNow(): Promise<CompactNowResult | null> {
+ const result = await store.compactNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ messagesSummarized: result.response.messagesSummarized,
+ messagesKept: result.response.messagesKept,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCompactPercent(
+ percent: number,
+ ): Promise<SaveCompactPercentResult | null> {
+ const result = await store.setCompactPercent(percent);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, percent: result.percent }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+{/key}
diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte
new file mode 100644
index 0000000..dcd5b47
--- /dev/null
+++ b/src/app/views/ComposerPane.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ /**
+ * The `composer` view — the send box with the status derivation that used to
+ * live in App.svelte (error > queued > running > idle) and the
+ * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer
+ * is a persistent column fixture, exactly like today.
+ */
+ import { Composer, type ComposerStatus } from "../../features/chat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Priority: error > queued > running > idle. `queued` (the turn is in flight
+ // but waiting for a concurrency slot — CR-13) wins over `running` so the
+ // corner shows a ring, not dots, during the wait. `turn-start` fires before
+ // the slot is granted, so `generating` is already true while
+ // `conversationStatus === "queued"`; the explicit queued check is what
+ // distinguishes the two.
+ const composerStatus = $derived.by<ComposerStatus>(() => {
+ if (store.activeChat.error) return "error";
+ const id = store.activeConversationId;
+ if (id !== null && store.conversationStatus(id) === "queued") return "queued";
+ return store.activeChat.generating ? "running" : "idle";
+ });
+</script>
+
+<Composer
+ onSend={(text, images) => store.send(text, images)}
+ onQueue={(text) => store.queueMessage(text)}
+ onStop={() => store.stopGeneration()}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ status={composerStatus}
+/>
diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte
new file mode 100644
index 0000000..d04307f
--- /dev/null
+++ b/src/app/views/ConcurrencyPane.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The `concurrency` view — per-provider concurrency limits + live status.
+ * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted
+ * across tab switches (no {#key}, like today).
+ */
+ import {
+ ConcurrencyView,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
+ type SaveConcurrencyLimit,
+ } from "../../features/concurrency";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (kept for structural consistency — AGENTS.md "contracts
+ // are the cross-unit surface").
+ const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
+ const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
+ store.setConcurrencyLimit(providerId, limit);
+ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
+ store.deleteConcurrencyLimit(providerId);
+ const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
+ const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
+ store.setConcurrencyCooldown(providerId, cooldownMs);
+</script>
+
+<ConcurrencyView
+ models={store.models}
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
+/>
diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte
new file mode 100644
index 0000000..7a2343e
--- /dev/null
+++ b/src/app/views/ExtensionsPane.svelte
@@ -0,0 +1,78 @@
+<script lang="ts">
+ /**
+ * The `extensions` view — the frontend-modules table (aggregated from each
+ * feature's public `manifest` so it can't drift from what's actually
+ * composed) plus the generic surface list. The backend's "Loaded Extensions"
+ * surface is a SEPARATE, backend-owned list. Unkeyed (like today).
+ */
+ import Table from "../../components/Table.svelte";
+ import { manifest as cacheWarmingManifest } from "../../features/cache-warming";
+ import { manifest as chatManifest } from "../../features/chat";
+ import { manifest as concurrencyManifest } from "../../features/concurrency";
+ import { manifest as conversationCacheManifest } from "../../features/conversation-cache";
+ import { manifest as cwdLspManifest } from "../../features/cwd-lsp";
+ import { manifest as computerManifest } from "../../features/computer";
+ import { manifest as heartbeatManifest } from "../../features/heartbeat";
+ import { manifest as layoutManifest } from "../../features/layout";
+ import { manifest as markdownManifest } from "../../features/markdown";
+ import { manifest as mcpManifest } from "../../features/mcp";
+ import { manifest as namedAgentsManifest } from "../../features/named-agents";
+ import { manifest as settingsManifest } from "../../features/settings";
+ import { manifest as smartScrollManifest } from "../../features/smart-scroll";
+ import { SurfaceView } from "../../features/surface-host";
+ import { manifest as surfaceHostManifest } from "../../features/surface-host";
+ import { manifest as systemPromptManifest } from "../../features/system-prompt";
+ import { manifest as tabsManifest } from "../../features/tabs";
+ import { manifest as viewsManifest } from "../../features/views";
+ import { manifest as visionManifest } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ // Surfaces with dedicated renderers elsewhere stay out of the generic list:
+ // cache-warming (its own view), message-queue (its own view), todo (the
+ // Tasks view). SurfaceView itself stays fully generic (it never switches on
+ // a surface id) — the FILTER is this view's data selection.
+ const CACHE_WARMING_ID = "cache-warming";
+ const MESSAGE_QUEUE_ID = "message-queue";
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ // FE features are internal units of this single repo, so there is no
+ // per-module version — they all share dispatch-web's version.
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ layoutManifest,
+ conversationCacheManifest,
+ markdownManifest,
+ cacheWarmingManifest,
+ cwdLspManifest,
+ mcpManifest,
+ computerManifest,
+ smartScrollManifest,
+ settingsManifest,
+ systemPromptManifest,
+ heartbeatManifest,
+ concurrencyManifest,
+ visionManifest,
+ namedAgentsManifest,
+ ].map((m) => [m.name, m.description] as const);
+</script>
+
+<section>
+ <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
+ <Table columns={MODULE_COLUMNS} rows={loadedModules} />
+</section>
+<section class="mt-4 flex flex-col gap-3">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
+ <SurfaceView
+ {spec}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ />
+ {/each}
+</section>
diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte
new file mode 100644
index 0000000..1bb2e86
--- /dev/null
+++ b/src/app/views/HeartbeatPane.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ /**
+ * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config
+ * + run history) with the store adapters. Opening a RUN raises the chrome
+ * port: the run modal is app shell state (outside the tree — layout-00
+ * decision 2). Unkeyed; the config + runs are per-workspace (like today).
+ */
+ import {
+ HeartbeatView,
+ type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
+ type HeartbeatRunView,
+ type HeartbeatRunsResult,
+ type HeartbeatStopResult,
+ } from "../../features/heartbeat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp —
+ // AGENTS.md "contracts are the cross-unit surface").
+ async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
+ return store.heartbeatConfig();
+ }
+
+ async function saveHeartbeatConfig(
+ patch: Parameters<typeof store.setHeartbeatConfig>[0],
+ ): Promise<HeartbeatConfigResult> {
+ return store.setHeartbeatConfig(patch);
+ }
+
+ async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
+ return store.heartbeatRuns();
+ }
+
+ async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ return store.stopHeartbeatRun(runId);
+ }
+
+ async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ return store.heartbeatNextRun();
+ }
+</script>
+
+<HeartbeatView
+ models={store.models}
+ loadConfig={loadHeartbeatConfig}
+ saveConfig={saveHeartbeatConfig}
+ loadRuns={loadHeartbeatRuns}
+ stopRun={stopHeartbeatRun}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ loadDefaultPrompt={() => store.loadSystemPrompt()}
+ loadNextRun={loadHeartbeatNextRun}
+ onOpenRun={(run) => ports.openHeartbeatRun(run)}
+/>
diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte
new file mode 100644
index 0000000..5645ace
--- /dev/null
+++ b/src/app/views/LspPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `lsp` view — loaded language servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function loadLspStatus(): Promise<LspStatusResult | null> {
+ const result = await store.lspStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
+{/key}
diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte
new file mode 100644
index 0000000..82e139d
--- /dev/null
+++ b/src/app/views/McpPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `mcp` view — loaded MCP servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { McpStatusView, type McpStatusResult } from "../../features/mcp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function loadMcpStatus(): Promise<McpStatusResult | null> {
+ const result = await store.mcpStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
+{/key}
diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte
new file mode 100644
index 0000000..46b9a22
--- /dev/null
+++ b/src/app/views/MessageQueuePane.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ /**
+ * The `message-queue` view — pending steering messages, rendered via the
+ * generic SurfaceView (dispatches on rendererId, never surface id). The
+ * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf
+ * always exists; an idle queue renders NOTHING.
+ */
+ import { SurfaceView } from "../../features/surface-host";
+ import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list — pending steering messages are tied to
+ // the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // The surface spec + whether it currently has pending messages.
+ const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
+ const hasQueuedMessages = $derived.by(() => {
+ const spec = messageQueueSpec;
+ if (spec === null) return false;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
+ if (field === undefined || field.kind !== "custom") return false;
+ const data = parseMessageQueuePayload(field.payload);
+ return data !== null && data.messages.length > 0;
+ });
+</script>
+
+{#if hasQueuedMessages && messageQueueSpec !== null}
+ <div class="px-4 pt-2">
+ <SurfaceView
+ spec={messageQueueSpec}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)}
+ />
+ </div>
+{/if}
diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte
new file mode 100644
index 0000000..0344e47
--- /dev/null
+++ b/src/app/views/ModelPane.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ /**
+ * The `model` view — the model selector + the per-conversation
+ * reasoning-effort / cwd / computer controls, with the store adapters that
+ * used to live in App.svelte. The controls re-mount per workspace
+ * conversation (the {#key} moved inside the wrapper) so inputs can't bleed
+ * across tabs — editable for a draft too.
+ */
+ import {
+ ModelSelector,
+ ReasoningEffortSelector,
+ type ThinkingSelection,
+ type ThinkingSelectionSaveResult,
+ } from "../../features/chat";
+ import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp";
+ import {
+ ComputerField,
+ type ComputerSaveResult,
+ type ComputerStatusResult,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ type TestComputerResult,
+ } from "../../features/computer";
+ import type { NamedAgentSelectionSnapshot } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ function handleSelectModel(model: string): void {
+ store.selectModel(model);
+ }
+
+ // A Named Agent selected in the model selector: resolve it to its raw model
+ // AND record the snapshot on the active conversation (plan §6/§7). The store
+ // persists the raw model to `/conversations/:id/model` + retains the snapshot
+ // on the tab.
+ function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void {
+ store.selectModel(snapshot.model, snapshot);
+ }
+
+ // Adapt the store's reasoning-effort + thinking results to the chat
+ // feature's combined selector port. The selector sends ONE selection ("off"
+ // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is
+ // a SEPARATE signal from the effort level: it persists `thinking: false`
+ // (the umans route maps that to `reasoning_effort: "none"`), leaving the
+ // effort level untouched so an off→on toggle restores it. A level ensures
+ // thinking is ON (the level is meaningless while thinking is off) then sets
+ // the effort level.
+ async function saveThinkingSelection(
+ selection: ThinkingSelection,
+ ): Promise<ThinkingSelectionSaveResult | null> {
+ if (selection === "off") {
+ const result = await store.setThinking(false);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: "off" }
+ : { ok: false, error: result.error };
+ }
+ // A level: enable thinking first if it is currently off, then set the level.
+ if (store.thinking === false) {
+ const on = await store.setThinking(true);
+ if (on !== null && !on.ok) {
+ return { ok: false, error: on.error };
+ }
+ }
+ const result = await store.setReasoningEffort(selection);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
+ const result = await store.setCwd(cwd);
+ if (result === null) return null;
+ return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
+ }
+
+ async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
+ const result = await store.setComputer(computerId);
+ if (result === null) return null;
+ return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
+ }
+
+ const loadComputerStatus: LoadComputerStatus = async (
+ alias: string,
+ ): Promise<ComputerStatusResult | null> => {
+ const result = await store.computerStatus(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
+ };
+
+ const testComputer: TestComputer = async (
+ alias: string,
+ ): Promise<TestComputerResult | null> => {
+ const result = await store.testComputer(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
+ };
+</script>
+
+<div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ modelInfo={store.modelInfo}
+ namedAgents={store.namedAgents}
+ namedAgentSnapshot={store.namedAgentSnapshot}
+ onSelectNamedAgent={handleSelectNamedAgent}
+ zeroProviders={store.enabledProviderCount === 0}
+ />
+ {#key store.currentConversationId}
+ <ReasoningEffortSelector
+ persistedEffort={store.reasoningEffort}
+ persistedThinking={store.thinking}
+ save={saveThinkingSelection}
+ />
+ <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
+ <ComputerField
+ computerId={store.computerId}
+ canEdit={true}
+ computers={store.computers}
+ save={saveComputer}
+ loadStatus={loadComputerStatus}
+ test={testComputer}
+ />
+ {/key}
+</div>
diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte
new file mode 100644
index 0000000..413141a
--- /dev/null
+++ b/src/app/views/NamedAgentsPane.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ /**
+ * The `named-agents` view — a global, collapsible sidebar view of curated
+ * presets. The store owns the list (authoritative for the model selector +
+ * agent tooling); the view drives CRUD via injected ports (auto-save).
+ * Unkeyed — the list is global (no {#key}, like today).
+ */
+ import {
+ NamedAgentsView,
+ type NamedAgentInput,
+ } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's Named Agent CRUD to the named-agents feature's ports.
+ const namedAgentPorts = {
+ create: (input: NamedAgentInput) => store.createNamedAgent(input),
+ update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input),
+ remove: (id: string) => store.deleteNamedAgent(id),
+ };
+</script>
+
+<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} />
diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte
new file mode 100644
index 0000000..67a88ec
--- /dev/null
+++ b/src/app/views/SettingsPane.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ /**
+ * The `settings` view — FE-local settings (the chat limit). Not
+ * conversation-scoped (no {#key}: the chat limit is global). A chat-limit
+ * RAISE refills the active chat (prepends older history) — the reader's
+ * viewport is preserved over the prepend via the chat viewport bridge
+ * (exactly like today's saveChatLimit).
+ */
+ import { tick } from "svelte";
+ import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = ports.chatViewport.el;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await store.setChatLimit(value);
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result.ok
+ ? { ok: true, chatLimit: result.chatLimit }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+</div>
diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte
new file mode 100644
index 0000000..0fb5a11
--- /dev/null
+++ b/src/app/views/SystemPromptPane.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ /**
+ * The `system-prompt` view — a pointer to the global system-prompt template
+ * that opens the full-page modal editor (half template / half variable
+ * palette). The modal itself is CHROME (App shell state, outside the tree —
+ * layout-00 decision 2); this view only raises the port. Unkeyed (like
+ * today).
+ */
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+</script>
+
+<div class="flex flex-col gap-2">
+ <p class="text-xs opacity-60">
+ Edit the global system prompt template with variable placeholders. Opens a full-page editor.
+ </p>
+ <button type="button" class="btn btn-primary btn-sm" onclick={() => ports.openSystemPromptBuilder()}>
+ Open builder
+ </button>
+</div>
diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte
new file mode 100644
index 0000000..f87b086
--- /dev/null
+++ b/src/app/views/TabsPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `tabs` view — the conversation tab list (moved out of the top bar into
+ * the sidebar). Re-mounts per workspace so the filtered tab set + scroll
+ * reset cleanly on a workspace switch (the {#key} moved inside the wrapper).
+ */
+ import { TabList } from "../../features/tabs";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+</script>
+
+{#key store.activeWorkspaceId}
+ <TabList
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ />
+{/key}
diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte
new file mode 100644
index 0000000..6a375b3
--- /dev/null
+++ b/src/app/views/TasksPane.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ /**
+ * The `tasks` view — the todo extension's per-conversation task list
+ * (model-maintained, read-only). Re-mounts per conversation so the task list
+ * is isolated per conversation (the {#key} moved inside the wrapper).
+ * TodoList always reserves its fixed height (empty or full).
+ */
+ import TodoList from "../../features/surface-host/ui/TodoList.svelte";
+ import { parseTodoPayload } from "../../features/surface-host/logic/todo";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The `todo` extension's per-conversation task list surface.
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const todoData = $derived.by(() => {
+ const spec = store.surface(TODO_ID);
+ if (spec === null) return null;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
+ if (field === undefined || field.kind !== "custom") return null;
+ return parseTodoPayload(field.payload);
+ });
+</script>
+
+{#key store.activeConversationId}
+ <TodoList payload={todoData} />
+{/key}
diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte
new file mode 100644
index 0000000..5d53545
--- /dev/null
+++ b/src/app/views/VisionPane.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ /**
+ * The `vision` view — global vision settings (image-compaction limit +
+ * model) with the store adapters. Not conversation-scoped (no {#key}), so
+ * the settings stay mounted across tab switches (like today).
+ */
+ import {
+ VisionSettingsView,
+ type LoadVisionSettingsResult,
+ type SaveVisionSettingsResult,
+ type VisionSettingsPatch,
+ } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's global vision-settings API to the vision feature's ports.
+ async function loadVisionSettings(): Promise<LoadVisionSettingsResult> {
+ // The store seeds `visionSettings` on boot; a refresh keeps it current.
+ await store.refreshVisionSettings();
+ const settings = store.visionSettings;
+ if (settings === null) {
+ return { ok: false, error: "Vision settings not available." };
+ }
+ return { ok: true, settings };
+ }
+
+ async function saveVisionSettings(
+ patch: VisionSettingsPatch,
+ ): Promise<SaveVisionSettingsResult> {
+ const result = await store.setVisionSettings(patch);
+ if (result === null) return { ok: false, error: "Vision settings not available." };
+ return result.ok
+ ? { ok: true, settings: result.settings }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+<VisionSettingsView
+ models={store.models}
+ modelInfo={store.modelInfo}
+ load={loadVisionSettings}
+ save={saveVisionSettings}
+/>
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
new file mode 100644
index 0000000..a2e93a6
--- /dev/null
+++ b/src/features/layout/index.ts
@@ -0,0 +1,40 @@
+/**
+ * The layout feature — a generic tile-tree renderer + the view registry.
+ *
+ * The frontend stops *having* a layout and starts *rendering* one: the tree is
+ * data (`@dispatch/layout-contract`), the renderer walks it, and every view a
+ * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays
+ * OUTSIDE the tree — the tree owns the content region.
+ */
+
+export {
+ bootDefaultOpen,
+ type CollapseState,
+ type CollapseStore,
+ type CollapseStoreOptions,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./logic/collapse";
+export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit";
+export {
+ getView,
+ listPanelViews,
+ listViews,
+ type RegisteredView,
+ registerView,
+ unregisterView,
+ type ViewComponent,
+ type ViewRegistration,
+ viewRegistry,
+} from "./logic/registry";
+export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed";
+export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query";
+export { default as TileRoot } from "./ui/TileRoot.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "layout",
+ description: "Tile-tree renderer + view registry",
+} as const;
diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts
new file mode 100644
index 0000000..77e9f3c
--- /dev/null
+++ b/src/features/layout/logic/collapse.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import {
+ bootDefaultOpen,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./collapse";
+
+function fakeStorage(): { storage: Storage; raw(key: string): string | null } {
+ const map = new Map<string, string>();
+ const storage: Storage = {
+ get length() {
+ return map.size;
+ },
+ clear: () => map.clear(),
+ getItem: (key) => map.get(key) ?? null,
+ key: () => null,
+ removeItem: (key) => map.delete(key),
+ setItem: (key, value) => void map.set(key, value),
+ };
+ return { storage, raw: (key) => map.get(key) ?? null };
+}
+
+describe("bootDefaultOpen", () => {
+ it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => {
+ expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true);
+ expect(bootDefaultOpen(1440)).toBe(true);
+ expect(bootDefaultOpen(1023)).toBe(false);
+ expect(bootDefaultOpen(0)).toBe(false);
+ });
+
+ it("defaults to open when the viewport is unknown (SSR/tests)", () => {
+ expect(bootDefaultOpen(undefined)).toBe(true);
+ });
+});
+
+describe("parsePersistedOpen", () => {
+ it("accepts today's bare JSON boolean format", () => {
+ expect(parsePersistedOpen("true")).toBe(true);
+ expect(parsePersistedOpen("false")).toBe(false);
+ });
+
+ it("ignores null, malformed JSON, and non-boolean values", () => {
+ expect(parsePersistedOpen(null)).toBeNull();
+ expect(parsePersistedOpen("{")).toBeNull();
+ expect(parsePersistedOpen('"true"')).toBeNull();
+ expect(parsePersistedOpen("{}")).toBeNull();
+ });
+});
+
+describe("createCollapseStore", () => {
+ it("uses the viewport default when nothing is persisted", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ expect(store.isOpen("side")).toBe(true);
+ const narrow = createCollapseStore({ viewportWidth: 800 });
+ expect(narrow.isOpen("side")).toBe(false);
+ });
+
+ it("restores the persisted boolean over the viewport default", () => {
+ const { storage } = fakeStorage();
+ storage.setItem(SIDEBAR_OPEN_KEY, "false");
+ const store = createCollapseStore({ storage, viewportWidth: 1440 });
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => {
+ const { storage, raw } = fakeStorage();
+ const store = createCollapseStore({ storage, viewportWidth: 1024 });
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("false");
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("true");
+ });
+
+ it("tracks per-node state reactively", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ const seen: boolean[] = [];
+ const unsubscribe = store.subscribe((state) =>
+ seen.push(state.openByNode.side ?? state.defaultOpen),
+ );
+ try {
+ store.setOpen("side", false);
+ store.setOpen("side", true);
+ } finally {
+ unsubscribe();
+ }
+ expect(seen).toEqual([true, false, true]);
+ expect(store.isOpen("side")).toBe(true);
+ });
+
+ it("an untracked node falls back to the default", () => {
+ const store = createCollapseStore({ viewportWidth: 800 });
+ expect(store.isOpen("other")).toBe(false);
+ });
+
+ it("survives storage failures (quota errors are swallowed)", () => {
+ const broken: Storage = {
+ get length() {
+ return 0;
+ },
+ clear: () => undefined,
+ getItem: () => null,
+ key: () => null,
+ removeItem: () => undefined,
+ setItem: () => {
+ throw new Error("quota exceeded");
+ },
+ };
+ const store = createCollapseStore({ storage: broken, viewportWidth: 1024 });
+ expect(() => store.toggle("side")).not.toThrow();
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("works without storage (session-only chrome state)", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ store.toggle("side");
+ expect(store.isOpen("side")).toBe(false);
+ });
+});
diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts
new file mode 100644
index 0000000..28e90b4
--- /dev/null
+++ b/src/features/layout/logic/collapse.ts
@@ -0,0 +1,95 @@
+/**
+ * Collapse chrome state — the open/closed state of the tree's collapsible
+ * split children (today: the sidebar). Renderer chrome, NOT tree data
+ * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store
+ * owns the per-node state, persisted device-locally.
+ *
+ * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open`
+ * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px,
+ * Tailwind `lg`). Today's UI has exactly one collapsible node, and the
+ * persisted boolean tracks the toggled node's state — identical bytes and
+ * behavior. A future with several collapsible nodes migrates the format in
+ * plan 02, not here.
+ */
+
+import { type Readable, writable } from "svelte/store";
+
+/** The localStorage key — unchanged from today (no migration). */
+export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open";
+/** Tailwind `lg`: today's boot default is open on wide screens. */
+export const WIDE_BREAKPOINT = 1024;
+
+export interface CollapseState {
+ /** Explicit per-node open state (node id → open). Absent = the default. */
+ readonly openByNode: Readonly<Record<string, boolean>>;
+ /** The boot default (persisted boolean, else viewport width >= 1024). */
+ readonly defaultOpen: boolean;
+}
+
+export interface CollapseStore extends Readable<CollapseState> {
+ /** Is the named node open? (explicit state, else the default) */
+ isOpen(nodeId: string): boolean;
+ setOpen(nodeId: string, open: boolean): void;
+ toggle(nodeId: string): void;
+}
+
+export interface CollapseStoreOptions {
+ /** Injected storage (the app store's `Storage`; undefined → session-only). */
+ readonly storage?: Storage | undefined;
+ /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */
+ readonly viewportWidth?: number | undefined;
+ /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */
+ readonly key?: string | undefined;
+}
+
+/** The boot default: open on wide screens (narrow screens start closed). */
+export function bootDefaultOpen(viewportWidth: number | undefined): boolean {
+ if (viewportWidth === undefined) return true;
+ return viewportWidth >= WIDE_BREAKPOINT;
+}
+
+/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */
+export function parsePersistedOpen(raw: string | null): boolean | null {
+ if (raw === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return typeof parsed === "boolean" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore {
+ const key = options?.key ?? SIDEBAR_OPEN_KEY;
+ const storage = options?.storage;
+ const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key));
+ const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth);
+
+ let openByNode: Record<string, boolean> = {};
+ const store = writable<CollapseState>({ openByNode, defaultOpen });
+
+ function set(nodeId: string, open: boolean): void {
+ openByNode = { ...openByNode, [nodeId]: open };
+ store.set({ openByNode, defaultOpen });
+ // Persist the toggled node's state as today's bare boolean. With one
+ // collapsible node (always, today) this is exactly today's behavior.
+ if (storage !== undefined) {
+ try {
+ storage.setItem(key, JSON.stringify(open));
+ } catch {
+ // Swallow quota / write errors — persistence is best-effort.
+ }
+ }
+ }
+
+ function isOpen(nodeId: string): boolean {
+ return openByNode[nodeId] ?? defaultOpen;
+ }
+
+ return {
+ subscribe: store.subscribe,
+ isOpen,
+ setOpen: (nodeId, open) => set(nodeId, open),
+ toggle: (nodeId) => set(nodeId, !isOpen(nodeId)),
+ };
+}
diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts
new file mode 100644
index 0000000..b4c3099
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit";
+
+describe("panel edit → op mapping", () => {
+ it("maps a dropdown swap to a replaceView on the panel leaf's path", () => {
+ expect(swapPanelOp([1], 2, "model")).toEqual({
+ type: "replaceView",
+ target: [1, 2],
+ view: "model",
+ });
+ });
+
+ it("maps the + button to an append insertPanel", () => {
+ expect(addPanelOp(["titlebar"], "tasks")).toEqual({
+ type: "insertPanel",
+ panels: ["titlebar"],
+ view: "tasks",
+ });
+ });
+
+ it("maps the ✕ button to a removeNode on the panel leaf's path", () => {
+ expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] });
+ });
+
+ it("the + button pre-selects the first eligible view (or null when none)", () => {
+ expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs");
+ expect(firstPanelView([])).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts
new file mode 100644
index 0000000..8a56859
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.ts
@@ -0,0 +1,33 @@
+/**
+ * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels'
+ * dropdown swap / add / remove buttons. The component builds the ops; the
+ * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero
+ * DOM.
+ */
+
+import type { LayoutOp, Path } from "@dispatch/layout-contract";
+
+/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */
+export function swapPanelOp(path: Path, index: number, view: string): LayoutOp {
+ return { type: "replaceView", target: [...path, index], view };
+}
+
+/** Add a panel mounting `view` (appended to the panels node at `path`). */
+export function addPanelOp(path: Path, view: string): LayoutOp {
+ return { type: "insertPanel", panels: path, view };
+}
+
+/** Remove the panel at `index` from the panels node at `path`. */
+export function removePanelOp(path: Path, index: number): LayoutOp {
+ return { type: "removeNode", target: [...path, index] };
+}
+
+/**
+ * The view a new panel starts with. The tree schema has no "empty panel"
+ * state (a leaf's `view` is required — today's "Select a view" placeholder has
+ * no tree representation), so the + button pre-selects the first panel-eligible
+ * view. Returns null when nothing is eligible (the + button hides).
+ */
+export function firstPanelView(views: readonly { readonly id: string }[]): string | null {
+ return views[0]?.id ?? null;
+}
diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts
new file mode 100644
index 0000000..443e50d
--- /dev/null
+++ b/src/features/layout/logic/registry.test.ts
@@ -0,0 +1,80 @@
+import type { Component } from "svelte";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getView,
+ listPanelViews,
+ listViews,
+ registerView,
+ unregisterView,
+ type ViewRegistration,
+ viewRegistry,
+} from "./registry";
+
+const fakeComponent: Component = () => {
+ throw new Error("fixture component should never be constructed");
+};
+
+function registration(overrides?: Partial<ViewRegistration>): ViewRegistration {
+ return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides };
+}
+
+const registeredIds = (): string[] => listViews().map((v) => v.id);
+
+afterEach(() => {
+ for (const id of registeredIds()) unregisterView(id);
+});
+
+describe("view registry", () => {
+ it("registers a view and exposes it via getView/listViews", () => {
+ registerView(registration());
+ expect(getView("test-view")?.label).toBe("Test View");
+ expect(registeredIds()).toContain("test-view");
+ });
+
+ it("throws loudly on a duplicate id", () => {
+ registerView(registration());
+ expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/);
+ });
+
+ it("normalizes panelEligible to true by default and respects false", () => {
+ registerView(registration({ id: "a" }));
+ registerView(registration({ id: "b", panelEligible: false }));
+ expect(getView("a")?.panelEligible).toBe(true);
+ expect(getView("b")?.panelEligible).toBe(false);
+ expect(listPanelViews().map((v) => v.id)).toContain("a");
+ expect(listPanelViews().map((v) => v.id)).not.toContain("b");
+ });
+
+ it("keeps pluginId when given", () => {
+ registerView(registration({ id: "plugin-view", pluginId: "my-plugin" }));
+ expect(getView("plugin-view")?.pluginId).toBe("my-plugin");
+ });
+
+ it("unregisterView removes the view (idempotent for unknown ids)", () => {
+ registerView(registration({ id: "gone" }));
+ unregisterView("gone");
+ expect(getView("gone")).toBeUndefined();
+ expect(() => unregisterView("never-registered")).not.toThrow();
+ });
+
+ it("is reactive: subscribers see registrations and unregistrations", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id)));
+ try {
+ registerView(registration({ id: "reactive-a" }));
+ registerView(registration({ id: "reactive-b" }));
+ unregisterView("reactive-a");
+ } finally {
+ unsubscribe();
+ }
+ expect(seen[0]).toEqual([]);
+ expect(seen.at(-1)).toEqual(["reactive-b"]);
+ });
+
+ it("preserves registration order in listViews", () => {
+ registerView(registration({ id: "z-first" }));
+ registerView(registration({ id: "a-second" }));
+ const ids = registeredIds();
+ expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second"));
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
new file mode 100644
index 0000000..b072d2f
--- /dev/null
+++ b/src/features/layout/logic/registry.ts
@@ -0,0 +1,107 @@
+/**
+ * The view registry — the map from view id → component that tile leaves mount.
+ *
+ * Every view the renderer can place (the 14 sidebar views + chat / composer /
+ * message-queue today; plugin views from plan 03 later) registers here. The
+ * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
+ * open dropdowns, and the tree renderer resolves leaves through it per render.
+ *
+ * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02)
+ * can name views a given client lacks; the renderer shows a fallback tile.
+ *
+ * This module holds a small amount of reactive state by design (the plan places
+ * the registry in `logic/` with Svelte-store reactivity). It is NOT decision
+ * logic — no DOM, no effects.
+ */
+
+import type { Component } from "svelte";
+import { type Readable, writable } from "svelte/store";
+
+/**
+ * The props type of a registered view component. The composition root hands
+ * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`);
+ * fixture/generic components may ignore it.
+ *
+ * The registry is a HETEROGENEOUS component bag (the plan types it `Component`
+ * outright), so its props are opaque `Record<string, any>` — the wrappers
+ * re-narrow to their concrete props at the composition-root seam.
+ */
+export type ViewComponent =
+ // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag
+ Component<Record<string, any>>;
+
+export interface ViewRegistration {
+ /** Registry id — the same id a leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */
+ readonly component: ViewComponent;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+ /** The plugin that contributed the view (plan 03 requires it for those). */
+ readonly pluginId?: string;
+}
+
+/** A registered view with `panelEligible` normalized (default true). */
+export interface RegisteredView {
+ readonly id: string;
+ readonly label: string;
+ readonly component: ViewComponent;
+ readonly panelEligible: boolean;
+ readonly pluginId: string | undefined;
+}
+
+const byId = new Map<string, RegisteredView>();
+const store = writable<readonly RegisteredView[]>([]);
+
+function publish(): void {
+ store.set([...byId.values()]);
+}
+
+/**
+ * Register a view. A duplicate id throws LOUDLY at dev time — two components
+ * claiming one id is a composition-root bug, never a runtime fallback case.
+ */
+export function registerView(registration: ViewRegistration): void {
+ if (byId.has(registration.id)) {
+ throw new Error(`registerView: duplicate view id "${registration.id}"`);
+ }
+ byId.set(registration.id, {
+ id: registration.id,
+ label: registration.label,
+ component: registration.component,
+ panelEligible: registration.panelEligible ?? true,
+ pluginId: registration.pluginId,
+ });
+ publish();
+}
+
+/** Remove a view. Idempotent (removing an unknown id is a no-op). */
+export function unregisterView(id: string): void {
+ if (byId.delete(id)) publish();
+}
+
+/** Look a view up by id (undefined → the renderer shows its fallback tile). */
+export function getView(id: string): RegisteredView | undefined {
+ return byId.get(id);
+}
+
+/** All registered views, in registration order (snapshot). */
+export function listViews(): readonly RegisteredView[] {
+ return [...byId.values()];
+}
+
+/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */
+export function listPanelViews(): readonly RegisteredView[] {
+ return listViews().filter((v) => v.panelEligible);
+}
+
+/**
+ * Reactive registry contents (registration order). Subscribe in components —
+ * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03)
+ * re-render open dropdowns.
+ */
+export const viewRegistry: Readable<readonly RegisteredView[]> = {
+ subscribe: store.subscribe,
+};
diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts
new file mode 100644
index 0000000..dfe166a
--- /dev/null
+++ b/src/features/layout/logic/seed.test.ts
@@ -0,0 +1,99 @@
+import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed";
+import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query";
+
+function sideViews(tree: LayoutTree): string[] {
+ const root = tree.root as SplitNode;
+ const side = root.children[1]?.node;
+ return side?.kind === "panels" ? side.panels.map((p) => p.view) : [];
+}
+
+describe("seedLayout", () => {
+ it("uses the default panels when nothing is stored", () => {
+ const tree = seedLayout(null, ["tabs", "model", "settings"]);
+ expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS);
+ });
+
+ it("overlays the stored selection, dropping unknown/removed kinds", () => {
+ const tree = seedLayout(
+ ["extensions", "ghost-view", "model"],
+ ["tabs", "model", "extensions", "settings"],
+ );
+ expect(sideViews(tree)).toEqual(["extensions", "model"]);
+ });
+
+ it("falls back to the defaults when the stored selection is all-unknown or empty", () => {
+ expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("falls back to the defaults when the stored value is not a string array", () => {
+ expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("preserves the default tree's ids and the titlebar-unset invariant", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ expect((tree.root as SplitNode).id).toBe("root");
+ expect(tree.titlebar).toBeUndefined();
+ });
+});
+
+describe("overlaySidePanels", () => {
+ it("replaces only the side panels of the given base tree", () => {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]);
+ expect(sideViews(tree)).toEqual(["settings", "tasks"]);
+ // The rest of the tree is untouched.
+ expect((tree.root as SplitNode).children[0]?.node).toEqual(
+ (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node,
+ );
+ });
+});
+
+describe("tree queries", () => {
+ it("firstPanelsNode finds the side panels with its path", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ const found = firstPanelsNode(tree);
+ expect(found?.path).toEqual([1]);
+ expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]);
+ });
+
+ it("firstCollapsibleNodeId returns the default tree's side node id", () => {
+ expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side");
+ });
+
+ it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [
+ { size: 1, node: { kind: "leaf", view: "chat" } },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] },
+ },
+ ],
+ },
+ };
+ // The collapsible child sits at root path index 1 → key "1".
+ expect(firstCollapsibleNodeId(tree)).toBe("1");
+ });
+
+ it("firstCollapsibleNodeId returns null when nothing is collapsible", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [{ size: 1, node: { kind: "leaf", view: "chat" } }],
+ },
+ };
+ expect(firstCollapsibleNodeId(tree)).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts
new file mode 100644
index 0000000..7cd0ba3
--- /dev/null
+++ b/src/features/layout/logic/seed.ts
@@ -0,0 +1,78 @@
+/**
+ * Boot-time tree seeding: the default layout with today's sidebar selection
+ * overlaid from localStorage. Byte-compatible with today's boot behavior
+ * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree
+ * schema has no "unknown view" panel state, so a stale id is skipped exactly
+ * like a view that no longer exists).
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutNode,
+ type LayoutTree,
+ type LeafNode,
+ type PanelsNode,
+ type Path,
+} from "@dispatch/layout-contract";
+
+/**
+ * Today's default sidebar selection (the moved-from-top tab bar + the model
+ * controls) — the fallback when nothing (or nothing valid) is stored.
+ */
+export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"];
+
+/**
+ * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed
+ * `dispatch.sidebar.views` value, or null when absent) overlaid onto the
+ * default tree's panels node.
+ *
+ * - null / non-array / array without any known view → the defaults.
+ * - Known views are kept in the stored order; unknown ids are dropped.
+ */
+export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree {
+ const known = new Set(panelViewIds);
+ const stored = Array.isArray(storedViews)
+ ? storedViews.filter((v): v is string => typeof v === "string")
+ : [];
+ const panels = stored.filter((v) => known.has(v));
+ const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v));
+ return overlaySidePanels(DEFAULT_LAYOUT, selected);
+}
+
+/**
+ * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting
+ * `views` (in order). Targets the panels node by id (falls back to no-op when
+ * the base has none); the rest of the tree is shared, not copied.
+ */
+export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree {
+ const found = findById(base, "side");
+ if (found === null || found.node.kind !== "panels") return base;
+ const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view }));
+ const next: PanelsNode = { ...found.node, panels };
+ return { version: 1, root: replaceChild(base.root, found.path, next) };
+}
+
+/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */
+function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode {
+ if (path.length === 0) return replacement;
+ const [head, ...rest] = path;
+ if (typeof head !== "number") return node;
+ if (node.kind === "split") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ if (node.kind === "stack") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ return node;
+}
diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts
new file mode 100644
index 0000000..aa8dfe9
--- /dev/null
+++ b/src/features/layout/logic/tree-query.ts
@@ -0,0 +1,60 @@
+/**
+ * Tree queries the chrome needs — pure walks over the layout tree.
+ */
+
+import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract";
+
+/** The first panels node in the tree (the panel-selection owner), if any. */
+export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null {
+ const seen: { node: PanelsNode; path: Path }[] = [];
+ const visit = (node: LayoutNode, path: Path): void => {
+ if (seen.length > 0) return;
+ if (node.kind === "panels") {
+ seen.push({ node, path });
+ return;
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) visit(child, [...path, i]);
+ };
+ visit(tree.root, []);
+ return seen[0] ?? null;
+}
+
+/**
+ * The node id of the tree's FIRST collapsible split child — the one the chrome
+ * top bar's toggle reads/writes (`findById("side")` on the default tree;
+ * generically the first collapsible child, root-first then titlebar). Falls
+ * back to a path-derived key when the child's node has no id. Null → the tree
+ * has nothing collapsible (the toggle button hides).
+ */
+export function firstCollapsibleNodeId(tree: LayoutTree): string | null {
+ const visit = (node: LayoutNode, path: Path): string | null => {
+ if (node.kind === "split") {
+ for (const [i, child] of node.children.entries()) {
+ if (!child.collapsible) continue;
+ if (child.node.id !== undefined) return child.node.id;
+ return [...path, i].map((s) => String(s)).join(":");
+ }
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) {
+ const found = visit(child, [...path, i]);
+ if (found !== null) return found;
+ }
+ return null;
+ };
+ const inRoot = visit(tree.root, []);
+ if (inRoot !== null) return inRoot;
+ if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]);
+ return null;
+}
diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte
new file mode 100644
index 0000000..8fafa7f
--- /dev/null
+++ b/src/features/layout/ui/FallbackTile.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ /**
+ * Shown when a leaf names a view this client has not registered — the tree
+ * is data and can arrive from a backend (plan 02) that knows views this
+ * build lacks. A titled placeholder, never a crash.
+ */
+ let { viewId }: { viewId: string } = $props();
+</script>
+
+<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile">
+ <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3>
+ <p class="text-sm opacity-60">View not registered.</p>
+</div>
diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte
new file mode 100644
index 0000000..cd61b6b
--- /dev/null
+++ b/src/features/layout/ui/TileLeaf.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ /**
+ * Mounts a registered view for a leaf node. Resolution goes through the
+ * REACTIVE registry, so a late registration (plan 03) swaps the tile in
+ * without a tree change. An unknown view id renders a fallback tile —
+ * NEVER a crash (backend-owned trees can name views this client lacks).
+ */
+ import { viewRegistry, type RegisteredView } from "../logic/registry";
+ import FallbackTile from "./FallbackTile.svelte";
+
+ let { node, ports }: { node: { readonly view: string }; ports?: unknown } = $props();
+
+ const entry: RegisteredView | undefined = $derived(
+ $viewRegistry.find((v) => v.id === node.view),
+ );
+ const View = $derived(entry?.component);
+</script>
+
+{#if View}
+ <View {ports} />
+{:else}
+ <FallbackTile viewId={node.view} />
+{/if}
diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte
new file mode 100644
index 0000000..5ed8a89
--- /dev/null
+++ b/src/features/layout/ui/TileNode.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The recursive tile dispatcher: renders the node for its `kind` and recurses
+ * through the container components. Leaf nodes resolve through the view
+ * registry (TileLeaf).
+ */
+ import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileLeaf from "./TileLeaf.svelte";
+ import TilePanels from "./TilePanels.svelte";
+ import TileSplit from "./TileSplit.svelte";
+ import TileStack from "./TileStack.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: LayoutNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+{#if node.kind === "split"}
+ <TileSplit {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "stack"}
+ <TileStack {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "panels"}
+ <TilePanels {node} {path} {ports} {onEdit} />
+{:else if node.kind === "leaf"}
+ <TileLeaf {node} {ports} />
+{/if}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
new file mode 100644
index 0000000..ca597fe
--- /dev/null
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ /**
+ * A scrolling stack of swappable panels — the generalized ViewSidebar. The
+ * panel list comes from the tree node; dropdown options from the (reactive)
+ * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`;
+ * the OWNER (App today, the backend store in plan 02) applies them and
+ * decides persistence.
+ */
+ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
+ import { viewRegistry } from "../logic/registry";
+ import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import TileLeaf from "./TileLeaf.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ onEdit,
+ }: {
+ node: PanelsNode;
+ path: Path;
+ ports?: unknown;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+
+ // Panel-eligible views only (decision 5): chat/composer/message-queue never
+ // appear in the dropdowns. Reactive so late registrations re-render options.
+ const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
+ const addView = $derived(firstPanelView(panelViews));
+</script>
+
+<div class="flex min-h-0 flex-col gap-2">
+ {#each node.panels as panel, idx (idx)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => onEdit([removePanelOp(path, idx)])}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
+ </div>
+ {/each}
+
+ {#if addView !== null}
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])}
+ >
+ +
+ </button>
+ {/if}
+</div>
diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte
new file mode 100644
index 0000000..82ad430
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The tile-tree root. Renders the tree (titlebar slot first — unset renders
+ * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the
+ * new tree via `onTreeChange`. The OWNER decides what persistence means
+ * (App/localStorage today; the backend layout store in plan 02).
+ */
+ import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ tree,
+ onTreeChange,
+ ports,
+ collapse,
+ }: {
+ tree: LayoutTree;
+ onTreeChange?: (tree: LayoutTree) => void;
+ ports?: unknown;
+ collapse: CollapseStore;
+ } = $props();
+
+ function handleEdit(ops: readonly LayoutOp[]): void {
+ const result = applyOps(tree, ops);
+ if (result.ok) {
+ onTreeChange?.(result.tree);
+ } else {
+ // A panel edit that fails validation (e.g. removing the last panel) is
+ // refused loudly in dev — the tree keeps its previous state.
+ console.error(`TileRoot: layout edit refused: ${result.error}`);
+ }
+ }
+</script>
+
+{#if tree.titlebar !== undefined}
+ <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} />
+{/if}
+<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} />
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
new file mode 100644
index 0000000..27b8e0a
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -0,0 +1,228 @@
+import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { createCollapseStore, registerView, unregisterView } from "../index";
+import FixtureA from "./fixtures/FixtureA.svelte";
+import FixtureB from "./fixtures/FixtureB.svelte";
+import FixtureC from "./fixtures/FixtureC.svelte";
+import TileRoot from "./TileRoot.svelte";
+
+// Fixture registry: two panel-eligible views + one content-only view (a THIRD
+// component, so every fixture testid is unique in the rendered tree).
+registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
+registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
+registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });
+
+function leaf(view: string): { kind: "leaf"; view: string } {
+ return { kind: "leaf", view };
+}
+
+/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
+function defaultTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
+function panelsOnlyTree(...views: string[]): LayoutTree {
+ return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
+}
+
+function sidePanelsOf(tree: LayoutTree | null): string[] {
+ if (tree === null) return [];
+ const side = (tree.root as SplitNode).children?.[1]?.node;
+ return side?.kind === "panels"
+ ? side.panels.map((p) => p.view)
+ : tree.root.kind === "panels"
+ ? tree.root.panels.map((p) => p.view)
+ : [];
+}
+
+describe("TileRoot", () => {
+ it("renders every node kind: split, stack, panels, leaf", () => {
+ // A FRESH collapse store per test — the store is stateful chrome.
+ render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ // Stack children mount their registered components.
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
+ // The panels node renders its panel leaf + the dropdown.
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
+ });
+
+ it("shows a fallback tile (never a crash) for an unknown view id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "not-registered-anywhere" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fallback = screen.getByTestId("fallback-tile");
+ expect(fallback).toHaveTextContent("not-registered-anywhere");
+ expect(fallback).toHaveTextContent("View not registered.");
+ });
+
+ it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
+ });
+
+ it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ titlebar: { kind: "leaf", view: "fix-a" },
+ root: { kind: "leaf", view: "fix-b" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ });
+
+ it("the collapsible child reflects the collapse store and toggles through it", async () => {
+ const collapse = createCollapseStore({ viewportWidth: 1024 });
+ const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
+ const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
+ expect(aside).not.toBeNull();
+ // Wide viewport default: open → full basis width.
+ expect(aside).toHaveAttribute("data-open", "true");
+ expect(aside?.style.width).toBe("320px");
+
+ await collapse.toggle("side");
+ expect(aside).toHaveAttribute("data-open", "false");
+ expect(aside?.style.width).toBe("0px");
+ });
+
+ it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
+ expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
+ // TileRoot is a CONTROLLED component: applying the op is the owner's job
+ // (App re-renders with the new tree; the test doesn't feed it back).
+ });
+
+ it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
+ });
+
+ it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b", "fix-a"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ // Only panels after the first can be removed — like ViewSidebar today.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ await user.click(removeButtons[0] as HTMLElement);
+ expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
+ });
+
+ it("an invalid edit is refused: the first panel has no remove button", () => {
+ const tree = panelsOnlyTree("fix-b");
+ let changed: LayoutTree | null = null;
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, {
+ props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
+ });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ expect(changed).toBeNull();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
+ expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
+ const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
+ expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
+ });
+
+ it("the dropdown lists panel-eligible views only (decision 5)", () => {
+ render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
+ const select = screen.getByRole("combobox", { name: "Select a view" });
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toContain("Alpha View");
+ expect(labels).toContain("Beta View");
+ expect(labels).not.toContain("Content");
+ });
+
+ it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
+ render(TileRoot, {
+ props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
+ });
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ });
+});
+
+describe("TileRoot registry cleanup", () => {
+ it("unregisters the fixture views (the registry is module-global per test file)", () => {
+ unregisterView("fix-a");
+ unregisterView("fix-b");
+ unregisterView("fix-content");
+ const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ // Unregistered → the fallback tile, not a crash.
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+});
diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte
new file mode 100644
index 0000000..1f0ec64
--- /dev/null
+++ b/src/features/layout/ui/TileSplit.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ /**
+ * A flex row/column of child subtrees. Child sizing comes from the tree:
+ * `size` → flex-grow (+ optional `basis`), and `collapsible` children get
+ * today's EXACT sidebar behavior — width/translate transition, `lg:relative`
+ * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state
+ * is chrome (per-node-id), owned by the injected collapse store.
+ */
+ import type { LayoutOp, Path, SplitNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: SplitNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+
+ const state = $derived($collapse);
+
+ /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */
+ function keyOf(i: number, id: string | undefined): string {
+ return id ?? [...path, i].map((s) => String(s)).join(":");
+ }
+
+ function isOpen(i: number, id: string | undefined): boolean {
+ const key = keyOf(i, id);
+ return state.openByNode[key] ?? state.defaultOpen;
+ }
+</script>
+
+{#if node.dir === "col"}
+ <div class="flex min-h-0 w-full flex-col">
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+ </div>
+{:else}
+ <div class="flex min-h-0 h-full flex-row">
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ {#if child.collapsible}
+ {@const open = isOpen(i, child.node.id)}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <aside
+ class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
+ style="width: {open ? (child.basis ?? 320) : 0}px"
+ data-testid="collapsible-child"
+ data-open={open}
+ >
+ <div
+ class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
+ style="width: {child.basis ?? 320}px; transform: translateX({open ? '0' : '100%'})"
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ </aside>
+ {#if open}
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 z-20 bg-black/30 lg:hidden"
+ role="button"
+ tabindex="0"
+ aria-label="Close sidebar"
+ onclick={() => collapse.setOpen(key, false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false);
+ }}
+ ></div>
+ {/if}
+ {:else}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/if}
+ {/each}
+ </div>
+{/if}
diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte
new file mode 100644
index 0000000..13dab09
--- /dev/null
+++ b/src/features/layout/ui/TileStack.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+ /**
+ * A plain vertical container (today's left column). `grow` children flex
+ * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like
+ * today's `flex-1`); children without grow take natural height.
+ */
+ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: StackNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+<div class="flex min-h-0 min-w-0 flex-col overflow-hidden">
+ {#each node.children as child, i (i)}
+ <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}>
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+</div>
diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte
new file mode 100644
index 0000000..e55ba2a
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureA.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Alpha" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-a">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte
new file mode 100644
index 0000000..1c48584
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureB.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Beta" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-b">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte
new file mode 100644
index 0000000..2bceb67
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureC.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Gamma" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-c">{label}</div>