summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-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
20 files changed, 1138 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}
+/>