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