diff options
Diffstat (limited to 'src/app/views')
| -rw-r--r-- | src/app/views/AccountUsagePane.svelte | 22 | ||||
| -rw-r--r-- | src/app/views/CacheWarmingPane.svelte | 37 | ||||
| -rw-r--r-- | src/app/views/ChatPane.svelte | 117 | ||||
| -rw-r--r-- | src/app/views/CompactionPane.svelte | 48 | ||||
| -rw-r--r-- | src/app/views/ComposerPane.svelte | 43 | ||||
| -rw-r--r-- | src/app/views/ConcurrencyPane.svelte | 39 | ||||
| -rw-r--r-- | src/app/views/ExtensionsPane.svelte | 82 | ||||
| -rw-r--r-- | src/app/views/HeartbeatPane.svelte | 56 | ||||
| -rw-r--r-- | src/app/views/LspPane.svelte | 24 | ||||
| -rw-r--r-- | src/app/views/McpPane.svelte | 24 | ||||
| -rw-r--r-- | src/app/views/MessageQueuePane.svelte | 40 | ||||
| -rw-r--r-- | src/app/views/ModelPane.svelte | 131 | ||||
| -rw-r--r-- | src/app/views/NamedAgentsPane.svelte | 25 | ||||
| -rw-r--r-- | src/app/views/SettingsPane.svelte | 39 | ||||
| -rw-r--r-- | src/app/views/SystemPromptPane.svelte | 21 | ||||
| -rw-r--r-- | src/app/views/TabsPane.svelte | 25 | ||||
| -rw-r--r-- | src/app/views/TasksPane.svelte | 29 | ||||
| -rw-r--r-- | src/app/views/VisionPane.svelte | 45 |
18 files changed, 847 insertions, 0 deletions
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte new file mode 100644 index 0000000..09afb5f --- /dev/null +++ b/src/app/views/AccountUsagePane.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + /** + * The `account-usage` view — Claude + Codex + Hyper usage bars with + * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it + * stays mounted across tab switches (no {#key}). + */ + import { + AccountUsageView, + type LoadAccountUsage, + type SaveAccountUsageSettings, + } from "../../features/account-usage"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts); + const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) => + store.setAccountUsageSettings(provider, settings); +</script> + +<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} /> diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte new file mode 100644 index 0000000..d1d9b43 --- /dev/null +++ b/src/app/views/CacheWarmingPane.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The `cache-warming` view — the backend's conversation-scoped cache-warming + * surface with the WarmNow adapter. Re-mounts per conversation (like + * ChatView) so the view's local warming history / manual-warm feedback + * can't bleed across tabs (the {#key} moved inside the wrapper). + */ + import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming"; + import type { AppViewPorts } from "../view-ports"; + + const CACHE_WARMING_ID = "cache-warming"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. + async function warmNow(): Promise<WarmFeedback | null> { + const result = await store.warmNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + cachePct: result.response.cachePct, + expectedCacheRate: result.response.expectedCacheRate, + } + : { ok: false, error: result.error }; + } +</script> + +{#key store.activeConversationId} + <CacheWarmingView + spec={store.surface(CACHE_WARMING_ID)} + canWarm={store.activeConversationId !== null} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + {warmNow} + /> +{/key} diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte new file mode 100644 index 0000000..b5e05bb --- /dev/null +++ b/src/app/views/ChatPane.svelte @@ -0,0 +1,117 @@ +<script lang="ts"> + /** + * The `chat` view — the transcript region that moved out of App.svelte with + * ALL of its coupling: the smart-scroll controller (created here, reset on + * conversation switch like today's reset effect), the unload-gate attach + * effect, the follow-bottom effect, "show earlier" with viewport + * preservation, the ScrollToBottom affordance, and the empty-state + * watermark. Keyed on the active conversation exactly like today's ChatView. + */ + import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat"; + import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll"; + import { preserveViewportAcross, type AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + const textFiles = $derived.by(() => { + void store.activeWorkspaceId; + void store.textFileContextRevision; + const id = store.activeConversationId; + return id ? store.createTextFileClient(id) : undefined; + }); + $effect(() => { + const client = textFiles; + return () => client?.dispose(); + }); + + // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry). + const cacheTimer = $derived( + isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null, + ); + + // One controller owns the chat scroll region; the effects below feed it the + // edges (identical to the App.svelte wiring this moved out of). + const smartScroll = createSmartScrollController(); + let transcriptEl = $state<HTMLElement | undefined>(); + let transcriptContentEl = $state<HTMLElement | undefined>(); + + // The settings view's chat-limit save preserves the reader's viewport over + // the history refill — share the live scroll element through the bridge. + $effect(() => { + ports.chatViewport.el = transcriptEl ?? null; + }); + + // Chat-limit unload gate: old chunks may be unloaded only while the reader is + // stuck to the bottom. While stuck, a trim removes content far ABOVE the + // viewport and the controller re-pins to the bottom — no visible jump; while + // reading history, trimming is deferred instead of yanking the page (the old + // Dispatch bug). In an $effect so a swapped store prop would be re-wired. + $effect(() => { + store.attachUnloadGate(() => smartScroll.isAtBottom()); + }); + + // "Show earlier messages": page older history back in, preserving the reader's + // viewport position. + async function handleShowEarlier(): Promise<void> { + await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier()); + } + + // Attach/detach the controller to the live scroll element + content (disposed + // on unmount). The content element is observed (ResizeObserver) so the view + // follows height changes that aren't a transcript append. + $effect(() => { + if (!transcriptEl) return; + return smartScroll.attach(transcriptEl, transcriptContentEl); + }); + + // New transcript content streamed in (or messages loaded) → follow the bottom + // while stuck. Reads `chunks.length` so the effect re-runs on every append. + $effect(() => { + void store.activeChat.chunks.length; + smartScroll.contentChanged(); + }); + + // Conversation/tab switch → snap to the bottom of the new transcript. + $effect(() => { + void store.activeConversationId; + smartScroll.reset(); + }); +</script> + +<div class="relative h-full"> + <!-- The right gutter lives INSIDE the scroller (on the content), not outside + it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS) + take no layout space and paint over the scroller's right edge, so with + the gutter outside they drew on top of the tool/thinking cards. Now the + scroller spans to the pane edge and its scrollbar sits in the empty + gutter, clear of the content (classic scrollbars likewise). --> + <div bind:this={transcriptEl} class="h-full overflow-y-auto"> + <div bind:this={transcriptContentEl} class="pr-4"> + {#key store.activeConversationId} + <ChatView + chunks={store.activeChat.chunks} + recovering={store.activeChat.recovering} + turnMetrics={store.activeChat.turnMetrics} + hasEarlier={store.activeChat.hasEarlier} + onShowEarlier={handleShowEarlier} + thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} + apiBaseUrl={store.httpBase} + cwd={store.cwd} + {textFiles} + {cacheTimer} + /> + {/key} + <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} /> + </div> + </div> + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> + </div> + {/if} + <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> +</div> diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte new file mode 100644 index 0000000..ff31f0b --- /dev/null +++ b/src/app/views/CompactionPane.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + /** + * The `compaction` view — message compaction with the store adapters. + * Per-conversation (keyed so the percent + feedback can't bleed across tabs; + * the {#key} moved inside the wrapper). + */ + import { + CompactionView, + type CompactNowResult, + type SaveCompactPercentResult, + } from "../../features/chat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's compact result to the compaction view's port. + async function compactNow(): Promise<CompactNowResult | null> { + const result = await store.compactNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + messagesSummarized: result.response.messagesSummarized, + messagesKept: result.response.messagesKept, + } + : { ok: false, error: result.error }; + } + + async function saveCompactPercent( + percent: number, + ): Promise<SaveCompactPercentResult | null> { + const result = await store.setCompactPercent(percent); + if (result === null) return null; + return result.ok + ? { ok: true, percent: result.percent } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <CompactionView + percent={store.compactPercent} + canCompact={store.activeConversationId !== null} + {compactNow} + savePercent={saveCompactPercent} + /> +{/key} diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte new file mode 100644 index 0000000..00dbc9f --- /dev/null +++ b/src/app/views/ComposerPane.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + /** + * The `composer` view — the send box with the status derivation that used to + * live in App.svelte (error > queued > running > idle) and the + * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer + * is a persistent column fixture, exactly like today. + */ + import { Composer, type ComposerStatus } from "../../features/chat"; + import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const queuedMessageCount = $derived( + store.activeConversationId === null + ? undefined + : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)), + ); + + // Priority: error > queued > running > idle. `queued` (the turn is in flight + // but waiting for a concurrency slot — CR-13) wins over `running` so the + // corner shows a ring, not dots, during the wait. `turn-start` fires before + // the slot is granted, so `generating` is already true while + // `conversationStatus === "queued"`; the explicit queued check is what + // distinguishes the two. + const composerStatus = $derived.by<ComposerStatus>(() => { + if (store.activeChat.error) return "error"; + const id = store.activeConversationId; + if (id !== null && store.conversationStatus(id) === "queued") return "queued"; + return store.activeChat.generating ? "running" : "idle"; + }); +</script> + +<Composer + onSend={(text, images) => store.send(text, images)} + onQueue={(text, images) => store.queueMessage(text, images)} + onStop={() => store.stopGeneration()} + contextSize={store.activeChat.currentContextSize} + contextWindow={store.modelInfo[store.activeModel]?.contextWindow} + {queuedMessageCount} + status={composerStatus} +/> diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte new file mode 100644 index 0000000..d04307f --- /dev/null +++ b/src/app/views/ConcurrencyPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `concurrency` view — per-provider concurrency limits + live status. + * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted + * across tab switches (no {#key}, like today). + */ + import { + ConcurrencyView, + type DeleteConcurrencyLimit, + type LoadConcurrencyLimits, + type LoadConcurrencyStatus, + type SaveConcurrencyCooldown, + type SaveConcurrencyLimit, + } from "../../features/concurrency"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (kept for structural consistency — AGENTS.md "contracts + // are the cross-unit surface"). + const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits(); + const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) => + store.setConcurrencyLimit(providerId, limit); + const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) => + store.deleteConcurrencyLimit(providerId); + const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus(); + const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) => + store.setConcurrencyCooldown(providerId, cooldownMs); +</script> + +<ConcurrencyView + models={store.models} + loadLimits={loadConcurrencyLimits} + saveLimit={saveConcurrencyLimit} + deleteLimit={deleteConcurrencyLimit} + loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} +/> diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte new file mode 100644 index 0000000..d5baa39 --- /dev/null +++ b/src/app/views/ExtensionsPane.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + /** + * The `extensions` view — the frontend-modules table (aggregated from each + * feature's public `manifest` so it can't drift from what's actually + * composed) plus the generic surface list. The backend's "Loaded Extensions" + * surface is a SEPARATE, backend-owned list. Unkeyed (like today). + */ + import Table from "../../components/Table.svelte"; + import { manifest as accountUsageManifest } from "../../features/account-usage"; + import { manifest as cacheWarmingManifest } from "../../features/cache-warming"; + import { manifest as chatManifest } from "../../features/chat"; + import { manifest as concurrencyManifest } from "../../features/concurrency"; + import { manifest as conversationCacheManifest } from "../../features/conversation-cache"; + import { manifest as cwdLspManifest } from "../../features/cwd-lsp"; + import { manifest as computerManifest } from "../../features/computer"; + import { manifest as heartbeatManifest } from "../../features/heartbeat"; + import { manifest as layoutManifest } from "../../features/layout"; + import { manifest as markdownManifest } from "../../features/markdown"; + import { manifest as mcpManifest } from "../../features/mcp"; + import { manifest as namedAgentsManifest } from "../../features/named-agents"; + import { manifest as searchSettingsManifest } from "../../features/search-settings"; + import { manifest as settingsManifest } from "../../features/settings"; + import { manifest as smartScrollManifest } from "../../features/smart-scroll"; + import { SurfaceView } from "../../features/surface-host"; + import { manifest as surfaceHostManifest } from "../../features/surface-host"; + import { manifest as systemPromptManifest } from "../../features/system-prompt"; + import { manifest as tabsManifest } from "../../features/tabs"; + import { manifest as viewsManifest } from "../../features/views"; + import { manifest as visionManifest } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + // Surfaces with dedicated renderers elsewhere stay out of the generic list: + // cache-warming (its own view), message-queue (its own view), todo (the + // Tasks view). SurfaceView itself stays fully generic (it never switches on + // a surface id) — the FILTER is this view's data selection. + const CACHE_WARMING_ID = "cache-warming"; + const MESSAGE_QUEUE_ID = "message-queue"; + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const MODULE_COLUMNS = ["Module", "Description"] as const; + // FE features are internal units of this single repo, so there is no + // per-module version — they all share dispatch-web's version. + const loadedModules: readonly (readonly [string, string])[] = [ + chatManifest, + tabsManifest, + surfaceHostManifest, + viewsManifest, + layoutManifest, + conversationCacheManifest, + markdownManifest, + cacheWarmingManifest, + cwdLspManifest, + mcpManifest, + computerManifest, + smartScrollManifest, + settingsManifest, + systemPromptManifest, + heartbeatManifest, + concurrencyManifest, + accountUsageManifest, + visionManifest, + namedAgentsManifest, + searchSettingsManifest, + ].map((m) => [m.name, m.description] as const); +</script> + +<section> + <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> + <Table columns={MODULE_COLUMNS} rows={loadedModules} /> +</section> +<section class="mt-4 flex flex-col gap-3"> + <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> + {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} + <SurfaceView + {spec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + /> + {/each} +</section> diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte new file mode 100644 index 0000000..1bb2e86 --- /dev/null +++ b/src/app/views/HeartbeatPane.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + /** + * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config + * + run history) with the store adapters. Opening a RUN raises the chrome + * port: the run modal is app shell state (outside the tree — layout-00 + * decision 2). Unkeyed; the config + runs are per-workspace (like today). + */ + import { + HeartbeatView, + type HeartbeatConfigResult, + type HeartbeatNextRunResult, + type HeartbeatRunView, + type HeartbeatRunsResult, + type HeartbeatStopResult, + } from "../../features/heartbeat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp — + // AGENTS.md "contracts are the cross-unit surface"). + async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> { + return store.heartbeatConfig(); + } + + async function saveHeartbeatConfig( + patch: Parameters<typeof store.setHeartbeatConfig>[0], + ): Promise<HeartbeatConfigResult> { + return store.setHeartbeatConfig(patch); + } + + async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> { + return store.heartbeatRuns(); + } + + async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + return store.stopHeartbeatRun(runId); + } + + async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> { + return store.heartbeatNextRun(); + } +</script> + +<HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + loadVariables={() => store.loadSystemPromptVariables()} + loadDefaultPrompt={() => store.loadSystemPrompt()} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => ports.openHeartbeatRun(run)} +/> diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte new file mode 100644 index 0000000..5645ace --- /dev/null +++ b/src/app/views/LspPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `lsp` view — loaded language servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function loadLspStatus(): Promise<LspStatusResult | null> { + const result = await store.lspStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> +{/key} diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte new file mode 100644 index 0000000..82e139d --- /dev/null +++ b/src/app/views/McpPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `mcp` view — loaded MCP servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { McpStatusView, type McpStatusResult } from "../../features/mcp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function loadMcpStatus(): Promise<McpStatusResult | null> { + const result = await store.mcpStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} /> +{/key} diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte new file mode 100644 index 0000000..46b9a22 --- /dev/null +++ b/src/app/views/MessageQueuePane.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + /** + * The `message-queue` view — pending steering messages, rendered via the + * generic SurfaceView (dispatches on rendererId, never surface id). The + * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf + * always exists; an idle queue renders NOTHING. + */ + import { SurfaceView } from "../../features/surface-host"; + import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue"; + import type { AppViewPorts } from "../view-ports"; + + // The message-queue extension's per-conversation surface (steering). Pulled + // out of the generic Extensions list — pending steering messages are tied to + // the chat, not the sidebar. + const MESSAGE_QUEUE_ID = "message-queue"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // The surface spec + whether it currently has pending messages. + const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID)); + const hasQueuedMessages = $derived.by(() => { + const spec = messageQueueSpec; + if (spec === null) return false; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID); + if (field === undefined || field.kind !== "custom") return false; + const data = parseMessageQueuePayload(field.payload); + return data !== null && data.messages.length > 0; + }); +</script> + +{#if hasQueuedMessages && messageQueueSpec !== null} + <div class="px-4 pt-2"> + <SurfaceView + spec={messageQueueSpec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)} + /> + </div> +{/if} diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte new file mode 100644 index 0000000..0344e47 --- /dev/null +++ b/src/app/views/ModelPane.svelte @@ -0,0 +1,131 @@ +<script lang="ts"> + /** + * The `model` view — the model selector + the per-conversation + * reasoning-effort / cwd / computer controls, with the store adapters that + * used to live in App.svelte. The controls re-mount per workspace + * conversation (the {#key} moved inside the wrapper) so inputs can't bleed + * across tabs — editable for a draft too. + */ + import { + ModelSelector, + ReasoningEffortSelector, + type ThinkingSelection, + type ThinkingSelectionSaveResult, + } from "../../features/chat"; + import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp"; + import { + ComputerField, + type ComputerSaveResult, + type ComputerStatusResult, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + type TestComputerResult, + } from "../../features/computer"; + import type { NamedAgentSelectionSnapshot } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + function handleSelectModel(model: string): void { + store.selectModel(model); + } + + // A Named Agent selected in the model selector: resolve it to its raw model + // AND record the snapshot on the active conversation (plan §6/§7). The store + // persists the raw model to `/conversations/:id/model` + retains the snapshot + // on the tab. + function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void { + store.selectModel(snapshot.model, snapshot); + } + + // Adapt the store's reasoning-effort + thinking results to the chat + // feature's combined selector port. The selector sends ONE selection ("off" + // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is + // a SEPARATE signal from the effort level: it persists `thinking: false` + // (the umans route maps that to `reasoning_effort: "none"`), leaving the + // effort level untouched so an off→on toggle restores it. A level ensures + // thinking is ON (the level is meaningless while thinking is off) then sets + // the effort level. + async function saveThinkingSelection( + selection: ThinkingSelection, + ): Promise<ThinkingSelectionSaveResult | null> { + if (selection === "off") { + const result = await store.setThinking(false); + if (result === null) return null; + return result.ok + ? { ok: true, selection: "off" } + : { ok: false, error: result.error }; + } + // A level: enable thinking first if it is currently off, then set the level. + if (store.thinking === false) { + const on = await store.setThinking(true); + if (on !== null && !on.ok) { + return { ok: false, error: on.error }; + } + } + const result = await store.setReasoningEffort(selection); + if (result === null) return null; + return result.ok + ? { ok: true, selection: result.reasoningEffort } + : { ok: false, error: result.error }; + } + + async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { + const result = await store.setCwd(cwd); + if (result === null) return null; + return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; + } + + async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> { + const result = await store.setComputer(computerId); + if (result === null) return null; + return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error }; + } + + const loadComputerStatus: LoadComputerStatus = async ( + alias: string, + ): Promise<ComputerStatusResult | null> => { + const result = await store.computerStatus(alias); + if (result === null) return null; + return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error }; + }; + + const testComputer: TestComputer = async ( + alias: string, + ): Promise<TestComputerResult | null> => { + const result = await store.testComputer(alias); + if (result === null) return null; + return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error }; + }; +</script> + +<div class="flex flex-col gap-3"> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + modelInfo={store.modelInfo} + namedAgents={store.namedAgents} + namedAgentSnapshot={store.namedAgentSnapshot} + onSelectNamedAgent={handleSelectNamedAgent} + zeroProviders={store.enabledProviderCount === 0} + /> + {#key store.currentConversationId} + <ReasoningEffortSelector + persistedEffort={store.reasoningEffort} + persistedThinking={store.thinking} + save={saveThinkingSelection} + /> + <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + <ComputerField + computerId={store.computerId} + canEdit={true} + computers={store.computers} + save={saveComputer} + loadStatus={loadComputerStatus} + test={testComputer} + /> + {/key} +</div> diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte new file mode 100644 index 0000000..413141a --- /dev/null +++ b/src/app/views/NamedAgentsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `named-agents` view — a global, collapsible sidebar view of curated + * presets. The store owns the list (authoritative for the model selector + + * agent tooling); the view drives CRUD via injected ports (auto-save). + * Unkeyed — the list is global (no {#key}, like today). + */ + import { + NamedAgentsView, + type NamedAgentInput, + } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's Named Agent CRUD to the named-agents feature's ports. + const namedAgentPorts = { + create: (input: NamedAgentInput) => store.createNamedAgent(input), + update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input), + remove: (id: string) => store.deleteNamedAgent(id), + }; +</script> + +<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} /> diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte new file mode 100644 index 0000000..f119103 --- /dev/null +++ b/src/app/views/SettingsPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `settings` view — FE-local settings (the chat limit). Not + * conversation-scoped (no {#key}: the chat limit is global). A chat-limit + * RAISE refills the active chat (prepends older history) — the reader's + * viewport is preserved over the prepend via the chat viewport bridge + * (exactly like today's saveChatLimit). + */ + import { tick } from "svelte"; + import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings"; + import { SearchBackendField } from "../../features/search-settings"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> { + const el = ports.chatViewport.el; + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await store.setChatLimit(value); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result.ok + ? { ok: true, chatLimit: result.chatLimit } + : { ok: false, error: result.error }; + } +</script> + +<div class="flex flex-col gap-3"> + <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> + <SearchBackendField + load={() => store.searchBackendConfig()} + save={(backend) => store.setSearchBackend(backend)} + /> +</div> diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte new file mode 100644 index 0000000..0fb5a11 --- /dev/null +++ b/src/app/views/SystemPromptPane.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + /** + * The `system-prompt` view — a pointer to the global system-prompt template + * that opens the full-page modal editor (half template / half variable + * palette). The modal itself is CHROME (App shell state, outside the tree — + * layout-00 decision 2); this view only raises the port. Unkeyed (like + * today). + */ + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); +</script> + +<div class="flex flex-col gap-2"> + <p class="text-xs opacity-60"> + Edit the global system prompt template with variable placeholders. Opens a full-page editor. + </p> + <button type="button" class="btn btn-primary btn-sm" onclick={() => ports.openSystemPromptBuilder()}> + Open builder + </button> +</div> diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte new file mode 100644 index 0000000..4547e06 --- /dev/null +++ b/src/app/views/TabsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `tabs` view — the conversation tab list (moved out of the top bar into + * the sidebar). Re-mounts per workspace so the filtered tab set + scroll + * reset cleanly on a workspace switch (the {#key} moved inside the wrapper). + */ + import { TabList } from "../../features/tabs"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); +</script> + +{#key store.activeWorkspaceId} + <TabList + tabs={store.tabs} + activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)} + /> +{/key} diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte new file mode 100644 index 0000000..6a375b3 --- /dev/null +++ b/src/app/views/TasksPane.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + /** + * The `tasks` view — the todo extension's per-conversation task list + * (model-maintained, read-only). Re-mounts per conversation so the task list + * is isolated per conversation (the {#key} moved inside the wrapper). + * TodoList always reserves its fixed height (empty or full). + */ + import TodoList from "../../features/surface-host/ui/TodoList.svelte"; + import { parseTodoPayload } from "../../features/surface-host/logic/todo"; + import type { AppViewPorts } from "../view-ports"; + + // The `todo` extension's per-conversation task list surface. + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const todoData = $derived.by(() => { + const spec = store.surface(TODO_ID); + if (spec === null) return null; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID); + if (field === undefined || field.kind !== "custom") return null; + return parseTodoPayload(field.payload); + }); +</script> + +{#key store.activeConversationId} + <TodoList payload={todoData} /> +{/key} diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte new file mode 100644 index 0000000..5d53545 --- /dev/null +++ b/src/app/views/VisionPane.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + /** + * The `vision` view — global vision settings (image-compaction limit + + * model) with the store adapters. Not conversation-scoped (no {#key}), so + * the settings stay mounted across tab switches (like today). + */ + import { + VisionSettingsView, + type LoadVisionSettingsResult, + type SaveVisionSettingsResult, + type VisionSettingsPatch, + } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's global vision-settings API to the vision feature's ports. + async function loadVisionSettings(): Promise<LoadVisionSettingsResult> { + // The store seeds `visionSettings` on boot; a refresh keeps it current. + await store.refreshVisionSettings(); + const settings = store.visionSettings; + if (settings === null) { + return { ok: false, error: "Vision settings not available." }; + } + return { ok: true, settings }; + } + + async function saveVisionSettings( + patch: VisionSettingsPatch, + ): Promise<SaveVisionSettingsResult> { + const result = await store.setVisionSettings(patch); + if (result === null) return { ok: false, error: "Vision settings not available." }; + return result.ok + ? { ok: true, settings: result.settings } + : { ok: false, error: result.error }; + } +</script> + +<VisionSettingsView + models={store.models} + modelInfo={store.modelInfo} + load={loadVisionSettings} + save={saveVisionSettings} +/> |
