diff options
Diffstat (limited to 'src/app/App.svelte')
| -rw-r--r-- | src/app/App.svelte | 492 |
1 files changed, 435 insertions, 57 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 9225cc7..f0cd7ec 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { ReasoningEffort } from "@dispatch/transport-contract"; + import type { ImageInput } from "@dispatch/transport-contract"; import type { InvokeMessage } from "@dispatch/ui-contract"; import { tick } from "svelte"; import Table from "../components/Table.svelte"; @@ -16,12 +16,19 @@ ModelSelector, ReasoningEffortSelector, type CompactNowResult, - type ReasoningEffortSaveResult, + 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, @@ -35,20 +42,65 @@ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue"; import { parseTodoPayload } from "../features/surface-host/logic/todo"; import TodoList from "../features/surface-host/ui/TodoList.svelte"; - import { manifest as tabsManifest, TabBar } from "../features/tabs"; + import { manifest as tabsManifest, TabList } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import { CwdField, type CwdSaveResult, LspStatusView, type LspStatusResult, - manifest as workspaceManifest, - } from "../features/workspace"; + 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 }: { store: AppStore } = $props(); + 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 @@ -65,17 +117,23 @@ // The view kinds offered in the sidebar's dropdown. Generic data — the // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ + { id: "tabs", label: "Tabs" }, { id: "model", label: "Model" }, { id: "lsp", label: "Language Servers" }, + { id: "mcp", label: "MCP Servers" }, { id: "extensions", label: "Extensions" }, { id: "cache-warming", label: "Cache Warming" }, { id: "tasks", label: "Tasks" }, { id: "compaction", label: "Compaction" }, + { id: "heartbeat", label: "Heartbeat" }, + { id: "concurrency", label: "Concurrency" }, + { id: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: just the Model view. - const DEFAULT_VIEWS: readonly string[] = ["model"]; + // 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), }); @@ -99,9 +157,15 @@ conversationCacheManifest, markdownManifest, cacheWarmingManifest, - workspaceManifest, + cwdLspManifest, + mcpManifest, + computerManifest, smartScrollManifest, settingsManifest, + systemPromptManifest, + heartbeatManifest, + concurrencyManifest, + visionManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -173,6 +237,31 @@ 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; @@ -187,6 +276,10 @@ }); 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); @@ -196,14 +289,18 @@ store.invoke(msg.surfaceId, msg.actionId, msg.payload); } - function handleSend(text: string) { - store.send(text); + 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(); } @@ -225,14 +322,35 @@ : { ok: false, error: result.error }; } - // Adapt the store's reasoning-effort result to the chat feature's port. - async function saveReasoningEffort( - level: ReasoningEffort, - ): Promise<ReasoningEffortSaveResult | null> { - const result = await store.setReasoningEffort(level); + // 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, reasoningEffort: result.reasoningEffort } + ? { ok: true, selection: result.reasoningEffort } : { ok: false, error: result.error }; } @@ -259,6 +377,27 @@ : { 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 @@ -278,7 +417,7 @@ : { ok: false, error: result.error }; } - // Adapt the store's cwd/LSP results to the workspace feature's ports. + // 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; @@ -292,34 +431,149 @@ ? { 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); </script> <main class="relative flex h-screen overflow-hidden"> <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling - (below), so opening it shrinks this ENTIRE column — tab row included, which - slides the hamburger left. --> + (below), so opening it shrinks this ENTIRE column. --> <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> - <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with - a permanently seated hamburger pinned to the far right. --> - <div class="flex min-w-0 items-center"> - <TabBar - tabs={store.tabs} - activeConversationId={store.activeConversationId} - statusFor={(id) => store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> + <!-- 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)" > - {__APP_VERSION__} + build: {__APP_VERSION__} </span> <button class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label="Toggle sidebar" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} aria-expanded={sidebarOpen} onclick={() => (sidebarOpen = !sidebarOpen)} > @@ -332,11 +586,21 @@ class="size-5" aria-hidden="true" > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" - /> + {#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> @@ -355,7 +619,7 @@ </div> {/if} - <div class="relative min-h-0 min-w-0 flex-1"> + <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} @@ -365,6 +629,8 @@ hasEarlier={store.activeChat.hasEarlier} onShowEarlier={handleShowEarlier} thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} + apiBaseUrl={store.httpBase} /> {/key} </div> @@ -385,7 +651,11 @@ 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} /> + <SurfaceView + spec={messageQueueSpec} + onInvoke={handleInvoke} + onCancelQueuedMessage={handleCancelQueuedMessage} + /> </div> {/if} @@ -395,11 +665,7 @@ onStop={handleStop} contextSize={store.activeChat.currentContextSize} contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={store.activeChat.error - ? "error" - : store.activeChat.generating - ? "running" - : "idle"} + status={composerStatus} /> </div> @@ -412,7 +678,7 @@ class:w-0={!sidebarOpen} > <div - class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" + 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} /> @@ -435,16 +701,77 @@ {/if} </main> +{#if store.fatalError} + <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> +{/if} + +{#if systemPromptModalOpen} + <SystemPromptBuilder + loadPrompt={loadSystemPromptPrompt} + savePrompt={saveSystemPromptPrompt} + loadVariables={loadSystemPromptVariablesPrompt} + 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} +{/if} + {#snippet viewContent(kind: string)} - {#if kind === "model"} + {#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} /> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + modelInfo={store.modelInfo} + /> <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs re-mount per conversation — incl. switching between drafts — and can't bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). --> {#key store.currentConversationId} - <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} /> + <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 === "lsp"} @@ -452,6 +779,11 @@ {#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> @@ -475,16 +807,15 @@ /> {/key} {:else if kind === "tasks"} - <!-- Re-mount per conversation so the task list is isolated per conversation. --> + <!-- 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} - {#if todoData !== null && todoData.todos.length > 0} - <TodoList payload={todoData} /> - {:else} - <p class="text-xs opacity-60">No tasks yet.</p> - {/if} + <TodoList payload={todoData} /> {/key} {:else if kind === "compaction"} - <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. --> + <!-- Message compaction is per-conversation (keyed so the percent + feedback + can't bleed across tabs). Vision (image-compaction) settings are GLOBAL, + so they stay mounted across conversation switches (no {#key}). --> {#key store.currentConversationId} <CompactionView percent={store.compactPercent} @@ -493,11 +824,58 @@ savePercent={saveCompactPercent} /> {/key} + <div class="divider my-1 text-xs text-base-content/40">Image compaction</div> + <VisionSettingsView + models={store.models} + modelInfo={store.modelInfo} + load={loadVisionSettings} + save={saveVisionSettings} + /> + {:else if kind === "system-prompt"} + <!-- Global system prompt template. Opens a full-page modal editor (half + template / half variable palette). Not conversation-scoped (no {#key}). --> + <div class="flex flex-col gap-2"> + <p class="text-xs opacity-60"> + Edit the global system prompt template with variable placeholders. Opens a full-page editor. + </p> + <button + type="button" + class="btn btn-primary btn-sm" + onclick={() => (systemPromptModalOpen = true)} + > + Open builder + </button> + </div> {:else if kind === "settings"} <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is global), so the field stays mounted across tab switches. --> <div class="flex flex-col gap-3"> <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> </div> + {:else if kind === "heartbeat"} + <!-- Workspace-scoped autonomous-agent heartbeat (config + run history). + Not conversation-scoped (no {#key}); the config + runs are per-workspace. --> + <HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + loadVariables={loadSystemPromptVariablesPrompt} + loadDefaultPrompt={loadSystemPromptPrompt} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => (heartbeatRun = run)} + /> + {:else if kind === "concurrency"} + <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or + conversation-scoped), so the panel stays mounted across tab switches. --> + <ConcurrencyView + models={store.models} + loadLimits={loadConcurrencyLimits} + saveLimit={saveConcurrencyLimit} + deleteLimit={deleteConcurrencyLimit} + loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} + /> {/if} {/snippet} |
