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.svelte1041
1 files changed, 208 insertions, 833 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 7e5ac7d..d557149 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,847 +1,222 @@
-<script lang="ts">
- import type { ImageInput, 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 ComposerStatus,
- 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, TabList } 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 {
- 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: "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: "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,
- ].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 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 };
- }
+<script module lang="ts">
+ import { registerBuiltinViews } from "./register-views";
- // 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} />
- </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 { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout";
+ import { RunModal, type HeartbeatRunView } from "../features/heartbeat";
+ import { SystemPromptBuilder } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ 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 (plan 02): the ACTIVE workspace's tile tree, served
+ // by the backend's layout provider plugin when one is active (probed on
+ // boot, on workspace switch, and on extensions-changed; `layout` WS pushes
+ // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider
+ // mode: edits are ephemeral, logged once, no error UI). The store owns the
+ // whole lifecycle; the shell just renders `tree` and forwards edits.
+ // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's
+ // one-way §2.7 migration.
+ const tree = $derived(store.layout.tree);
+
+ function handleTreeChange(next: LayoutTree): void {
+ store.layout.onTreeChange(next);
+ }
+
+ // --- 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="h-app relative flex flex-col overflow-hidden">
+ <!-- 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).
+ `.app-navbar` (app.css): the Artix wiki's darkstrap navbar look. -->
+ <div class="app-navbar flex items-center justify-between gap-2 px-2 pt-[calc(0.5rem+5px)] pb-2">
+ <span
+ class="app-navbar-engraved min-w-0 flex-1 shrink truncate pl-2 text-sm font-semibold text-primary"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-primary/70"
+ title="Build version (git short hash)"
+ >
+ build: {__APP_VERSION__}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2 text-primary"
+ 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>
+ {#if collapseKey !== null}
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0 text-primary"
+ 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}
- />
- <!-- 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.
- 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). Vision (image-compaction) settings are GLOBAL,
- so they stay mounted across conversation switches (no {#key}). -->
- {#key store.currentConversationId}
- <CompactionView
- percent={store.compactPercent}
- canCompact={store.activeConversationId !== null}
- {compactNow}
- savePercent={saveCompactPercent}
- />
- {/key}
- <div class="divider my-1 text-xs text-base-content/40">Image compaction</div>
- <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}