summaryrefslogtreecommitdiffhomepage
path: root/src/app/views
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/views')
-rw-r--r--src/app/views/AccountUsagePane.svelte22
-rw-r--r--src/app/views/CacheWarmingPane.svelte37
-rw-r--r--src/app/views/ChatPane.svelte117
-rw-r--r--src/app/views/CompactionPane.svelte48
-rw-r--r--src/app/views/ComposerPane.svelte43
-rw-r--r--src/app/views/ConcurrencyPane.svelte39
-rw-r--r--src/app/views/ExtensionsPane.svelte82
-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.svelte39
-rw-r--r--src/app/views/SystemPromptPane.svelte21
-rw-r--r--src/app/views/TabsPane.svelte25
-rw-r--r--src/app/views/TasksPane.svelte29
-rw-r--r--src/app/views/VisionPane.svelte45
18 files changed, 847 insertions, 0 deletions
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte
new file mode 100644
index 0000000..09afb5f
--- /dev/null
+++ b/src/app/views/AccountUsagePane.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ /**
+ * The `account-usage` view — Claude + Codex + Hyper usage bars with
+ * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it
+ * stays mounted across tab switches (no {#key}).
+ */
+ import {
+ AccountUsageView,
+ type LoadAccountUsage,
+ type SaveAccountUsageSettings,
+ } from "../../features/account-usage";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts);
+ const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) =>
+ store.setAccountUsageSettings(provider, settings);
+</script>
+
+<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} />
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..b5e05bb
--- /dev/null
+++ b/src/app/views/ChatPane.svelte
@@ -0,0 +1,117 @@
+<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, PendingInputs, isClaudeSubscriptionModel } 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);
+ const textFiles = $derived.by(() => {
+ void store.activeWorkspaceId;
+ void store.textFileContextRevision;
+ const id = store.activeConversationId;
+ return id ? store.createTextFileClient(id) : undefined;
+ });
+ $effect(() => {
+ const client = textFiles;
+ return () => client?.dispose();
+ });
+
+ // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry).
+ const cacheTimer = $derived(
+ isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null,
+ );
+
+ // 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">
+ <!-- The right gutter lives INSIDE the scroller (on the content), not outside
+ it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS)
+ take no layout space and paint over the scroller's right edge, so with
+ the gutter outside they drew on top of the tool/thinking cards. Now the
+ scroller spans to the pane edge and its scrollbar sits in the empty
+ gutter, clear of the content (classic scrollbars likewise). -->
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl} class="pr-4">
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ recovering={store.activeChat.recovering}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ apiBaseUrl={store.httpBase}
+ cwd={store.cwd}
+ {textFiles}
+ {cacheTimer}
+ />
+ {/key}
+ <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} />
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.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..00dbc9f
--- /dev/null
+++ b/src/app/views/ComposerPane.svelte
@@ -0,0 +1,43 @@
+<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 { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const queuedMessageCount = $derived(
+ store.activeConversationId === null
+ ? undefined
+ : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)),
+ );
+
+ // 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, images) => store.queueMessage(text, images)}
+ onStop={() => store.stopGeneration()}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ {queuedMessageCount}
+ 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..d5baa39
--- /dev/null
+++ b/src/app/views/ExtensionsPane.svelte
@@ -0,0 +1,82 @@
+<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 accountUsageManifest } from "../../features/account-usage";
+ 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 searchSettingsManifest } from "../../features/search-settings";
+ 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,
+ accountUsageManifest,
+ visionManifest,
+ namedAgentsManifest,
+ searchSettingsManifest,
+ ].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..f119103
--- /dev/null
+++ b/src/app/views/SettingsPane.svelte
@@ -0,0 +1,39 @@
+<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 { SearchBackendField } from "../../features/search-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} />
+ <SearchBackendField
+ load={() => store.searchBackendConfig()}
+ save={(backend) => store.setSearchBackend(backend)}
+ />
+</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..4547e06
--- /dev/null
+++ b/src/app/views/TabsPane.svelte
@@ -0,0 +1,25 @@
+<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)}
+ onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)}
+ />
+{/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}
+/>