summaryrefslogtreecommitdiffhomepage
path: root/src/app/App.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/App.svelte')
-rw-r--r--src/app/App.svelte1199
1 files changed, 600 insertions, 599 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 78504bb..a3acaeb 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,613 +1,614 @@
<script lang="ts">
- import type { ReasoningEffort } 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 ReasoningEffortSaveResult,
- type SaveCompactPercentResult,
- } 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, TabBar } from "../features/tabs";
- import { manifest as viewsManifest, ViewSidebar } from "../features/views";
- 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 {
- SystemPromptBuilder,
- type LoadSystemPrompt as LoadSystemPromptAlias,
- type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
- type SaveSystemPrompt as SaveSystemPromptAlias,
- manifest as systemPromptManifest,
- } from "../features/system-prompt";
- import type { AppStore } from "./store.svelte";
- import ErrorModal from "./ErrorModal.svelte";
- import { createLocalStore } from "../adapters/local-storage";
- import { untrack } from "svelte";
-
- let { store }: { store: AppStore } = $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: "model", label: "Model" },
- { 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: "system-prompt", label: "System Prompt" },
- { id: "settings", label: "Settings" },
- ] as const;
-
- // Default sidebar layout: just the Model view.
- const DEFAULT_VIEWS: readonly string[] = ["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,
- ].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);
- });
-
- // 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);
-
- $effect(() => {
- sidebarOpenStore.save(sidebarOpen);
- });
-
- function handleInvoke(msg: InvokeMessage) {
- store.invoke(msg.surfaceId, msg.actionId, msg.payload);
- }
-
- function handleSend(text: string) {
- store.send(text);
- }
-
- function handleQueue(text: string) {
- store.queueMessage(text);
- }
-
- function handleStop() {
- store.stopGeneration();
- }
-
- function handleSelectModel(model: string) {
- store.selectModel(model);
- }
-
- // 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 result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
- if (result === null) return null;
- return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
-
- // 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 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);
+ import type { ReasoningEffort } 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 ReasoningEffortSaveResult,
+ type SaveCompactPercentResult,
+ } 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, TabBar } from "../features/tabs";
+ import { manifest as viewsManifest, ViewSidebar } from "../features/views";
+ 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 {
+ SystemPromptBuilder,
+ type LoadSystemPrompt as LoadSystemPromptAlias,
+ type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
+ type SaveSystemPrompt as SaveSystemPromptAlias,
+ manifest as systemPromptManifest,
+ } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import ErrorModal from "./ErrorModal.svelte";
+ import { createLocalStore } from "../adapters/local-storage";
+ import { untrack } from "svelte";
+
+ let { store }: { store: AppStore } = $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: "model", label: "Model" },
+ { 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: "system-prompt", label: "System Prompt" },
+ { id: "settings", label: "Settings" },
+ ] as const;
+
+ // Default sidebar layout: just the Model view.
+ const DEFAULT_VIEWS: readonly string[] = ["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,
+ ].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);
+ });
+
+ // 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);
+
+ $effect(() => {
+ sidebarOpenStore.save(sidebarOpen);
+ });
+
+ function handleInvoke(msg: InvokeMessage) {
+ store.invoke(msg.surfaceId, msg.actionId, msg.payload);
+ }
+
+ function handleSend(text: string) {
+ store.send(text);
+ }
+
+ function handleQueue(text: string) {
+ store.queueMessage(text);
+ }
+
+ function handleStop() {
+ store.stopGeneration();
+ }
+
+ function handleSelectModel(model: string) {
+ store.selectModel(model);
+ }
+
+ // 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 result to the chat feature's port.
+ async function saveReasoningEffort(
+ level: ReasoningEffort,
+ ): Promise<ReasoningEffortSaveResult | null> {
+ const result = await store.setReasoningEffort(level);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, reasoningEffort: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ // 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 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);
</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 — tab row included, which
- slides the hamburger left. -->
- <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
- a permanently seated hamburger pinned to the far right. -->
- <div class="flex min-w-0 items-center">
- <TabBar
- 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)}
- />
- <span
- class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
- title="Build version (git short hash)"
- >
- {__APP_VERSION__}
- </span>
- <button
- class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label="Toggle 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"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
- />
- </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">
- <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}
- />
- {/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} />
- </div>
- {/if}
-
- <Composer
- onSend={handleSend}
- onQueue={handleQueue}
- onStop={handleStop}
- contextSize={store.activeChat.currentContextSize}
- contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
- status={store.activeChat.error
- ? "error"
- : store.activeChat.generating
- ? "running"
- : "idle"}
- />
- </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 border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
- </div>
- </aside>
-
- <!-- 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}
+ <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
+ (below), so opening it shrinks this ENTIRE column — tab row included, which
+ slides the hamburger left. -->
+ <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
+ <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
+ a permanently seated hamburger pinned to the far right. -->
+ <div class="flex min-w-0 items-center">
+ <TabBar
+ 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)}
+ />
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
+ title="Build version (git short hash)"
+ >
+ {__APP_VERSION__}
+ </span>
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
+ aria-label="Toggle 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"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
+ />
+ </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">
+ <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}
+ />
+ {/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} />
+ </div>
+ {/if}
+
+ <Composer
+ onSend={handleSend}
+ onQueue={handleQueue}
+ onStop={handleStop}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ status={store.activeChat.error ? "error" : store.activeChat.generating ? "running" : "idle"}
+ />
+ </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 border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
+ style="transform: translateX({sidebarOpen ? '0' : '100%'})"
+ >
+ <ViewSidebar
+ kinds={viewKinds}
+ initial={sidebarPanels}
+ onChange={handleSidebarChange}
+ content={viewContent}
+ />
+ </div>
+ </aside>
+
+ <!-- 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>
{#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={loadSystemPromptPrompt}
+ savePrompt={saveSystemPromptPrompt}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
{/if}
{#snippet viewContent(kind: string)}
- {#if kind === "model"}
- <div class="flex flex-col gap-3">
- <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} />
- <!-- 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 persisted={store.reasoningEffort} save={saveReasoningEffort} />
- <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 === "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. -->
- {#key store.activeConversationId}
- {#if todoData !== null && todoData.todos.length > 0}
- <TodoList payload={todoData} />
- {:else}
- <p class="text-xs opacity-60">No tasks yet.</p>
- {/if}
- {/key}
- {:else if kind === "compaction"}
- <!-- Re-mount per conversation 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 === "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>
- {/if}
+ {#if kind === "model"}
+ <div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ />
+ <!-- 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 persisted={store.reasoningEffort} save={saveReasoningEffort} />
+ <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 === "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. -->
+ {#key store.activeConversationId}
+ {#if todoData !== null && todoData.todos.length > 0}
+ <TodoList payload={todoData} />
+ {:else}
+ <p class="text-xs opacity-60">No tasks yet.</p>
+ {/if}
+ {/key}
+ {:else if kind === "compaction"}
+ <!-- Re-mount per conversation 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 === "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>
+ {/if}
{/snippet}