diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 15:29:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 15:30:38 +0900 |
| commit | 5af9bd021c206b9e4330ab6a549dc8d013d91537 (patch) | |
| tree | 667dca977d6eb78e09006c5553523230daa2b73f /packages/frontend/src/lib/components | |
| parent | bcc449a5cba183c7358ab48ed4f2140bb1a3238c (diff) | |
| parent | bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a (diff) | |
| download | dispatch-5af9bd021c206b9e4330ab6a549dc8d013d91537.tar.gz dispatch-5af9bd021c206b9e4330ab6a549dc8d013d91537.zip | |
Merge branch 'dev' into warm/prompt-cache-warming
# Conflicts:
# packages/api/src/agent-manager.ts
# packages/api/tests/agent-manager.test.ts
# packages/frontend/src/lib/tabs.svelte.ts
Diffstat (limited to 'packages/frontend/src/lib/components')
5 files changed, 159 insertions, 6 deletions
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 4067b78..f3eadf7 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -46,6 +46,13 @@ const attachments = $derived(tabStore.activeTab?.attachments ?? []); const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null); const isRunning = $derived(agentStatus === "running"); +// Lock input while this tab is mid-compaction: either it's the source +// conversation being summarized, or it's the transient placeholder tab. +const compactLocked = $derived( + (tabStore.activeTab?.isCompacting ?? false) || + (tabStore.activeTab?.compactingSource ?? null) !== null || + (tabStore.activeTab?.compactionError ?? null) !== null, +); const hasText = $derived(inputValue.trim().length > 0); const hasAttachments = $derived(attachments.length > 0); // While generating with an empty box, the primary action is "stop". With text @@ -276,6 +283,8 @@ function handleKeydown(e: KeyboardEvent) { function submit() { if (!tabId) return; + // Block sending while this tab is mid-compaction (source or placeholder). + if (compactLocked) return; const map = new Map(attachments.map((a) => [a.id, a] as const)); const { displayText, content } = parseDraft(inputValue, map); const trimmed = displayText.trim(); @@ -314,7 +323,10 @@ function primaryAction() { bind:this={inputEl} value={inputValue} rows="1" - placeholder="Type a message... (paste an image or PDF to attach)" + placeholder={compactLocked + ? "Compaction in progress…" + : "Type a message... (paste an image or PDF to attach)"} + disabled={compactLocked} class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" onkeydown={handleKeydown} oninput={handleInput} @@ -325,7 +337,7 @@ function primaryAction() { <button type="button" class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}" - disabled={!showStop && !hasText && !hasAttachments || sendBlocked} + disabled={compactLocked || (!showStop && !hasText && !hasAttachments) || sendBlocked} onclick={primaryAction} title={showStop ? "Stop generation" : sendBlocked ? (attachmentWarning ?? "Cannot send") : "Send message"} > diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte index abdec17..ee1b8ef 100644 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ b/packages/frontend/src/lib/components/ChatPanel.svelte @@ -10,6 +10,11 @@ let isLoadingMore = $state(false); const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []); const activeTabId = $derived(tabStore.activeTab?.id); +// Compaction placeholder state for the active tab. `compactingSource` is set on +// a transient placeholder tab while a conversation is being compacted; +// `compactionError` is set if it failed. +const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null); +const compactionError = $derived(tabStore.activeTab?.compactionError ?? null); // Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}` // (n = its index among same-(turn,role) messages) rather than the underlying @@ -138,14 +143,28 @@ $effect(() => { {#if isLoadingMore} <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div> {/if} - {#if renderGroups.length === 0} + {#if compactingSource || compactionError} + <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center"> + {#if compactionError} + <div class="text-2xl font-semibold text-error">Compaction failed</div> + <div class="text-base text-base-content/70 max-w-md">{compactionError}</div> + <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div> + {:else} + <span class="loading loading-spinner loading-lg text-primary"></span> + <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div> + <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div> + {/if} + </div> + {:else if renderGroups.length === 0} <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> Send a message to start a conversation </div> {/if} - {#each keyedMessages as { m, key } (key)} - <ChatMessageComponent message={m} tabId={activeTabId} /> - {/each} + {#if !compactingSource && !compactionError} + {#each keyedMessages as { m, key } (key)} + <ChatMessageComponent message={m} tabId={activeTabId} /> + {/each} + {/if} </div> <!-- Scroll-to-bottom button --> diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte index 1e6cdf0..64036d4 100644 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -69,6 +69,9 @@ const modelCache = new Map<string, string[]>(); onReasoningChange, onAgentChange = (_agent: AgentInfo | null) => {}, onWorkingDirectoryChange = (_dir: string | null) => {}, + onCompact = () => {}, + canCompact = false, + compacting = false, }: { keys?: KeyInfo[]; activeTabId?: string | null; @@ -84,6 +87,9 @@ const modelCache = new Map<string, string[]>(); onReasoningChange: (effort: string) => void; onAgentChange?: (agent: AgentInfo | null) => void; onWorkingDirectoryChange?: (dir: string | null) => void; + onCompact?: () => void; + canCompact?: boolean; + compacting?: boolean; } = $props(); let showKeyModal = $state(false); @@ -251,6 +257,24 @@ const modelCache = new Map<string, string[]>(); </div> </div> + <!-- Compact conversation --> + <div class="mb-3"> + <button + type="button" + class="btn btn-sm btn-outline w-full" + disabled={!canCompact || compacting} + onclick={onCompact} + title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done." + > + {#if compacting} + <span class="loading loading-spinner loading-xs"></span> + Compacting… + {:else} + Compact conversation + {/if} + </button> + </div> + <!-- Toggle --> <div class="flex items-center gap-2 mb-3"> <button diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 7a810d5..8b28957 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -26,6 +26,10 @@ function selectTheme(theme: Theme): void { let titleKeyId = $state<string | null>(null); let titleModelId = $state<string | null>(null); let availableModels = $state<string[]>([]); +let compactionKeyId = $state<string | null>(null); +let compactionModelId = $state<string | null>(null); +let compactionModels = $state<string[]>([]); +let loadingCompactionModels = $state(false); let loadingModels = $state(false); let autoExpandThinking = $state(appSettings.autoExpandThinking); let localChunkLimit = $state(appSettings.chunkLimit); @@ -132,6 +136,19 @@ async function loadSettings(): Promise<void> { // ignore } try { + const res = await fetch(`${apiBase}/tabs/settings/compaction-model`); + if (res.ok) { + const data = (await res.json()) as { keyId: string | null; modelId: string | null }; + compactionKeyId = data.keyId; + if (compactionKeyId) { + await loadCompactionModelsForKey(compactionKeyId); + } + compactionModelId = data.modelId; + } + } catch { + // ignore + } + try { const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`); if (res.ok) { const data = (await res.json()) as { value: string | null }; @@ -319,6 +336,48 @@ async function onModelChange(e: Event): Promise<void> { saveTitleModel(); } +async function loadCompactionModelsForKey(keyId: string): Promise<void> { + loadingCompactionModels = true; + try { + const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`); + if (!res.ok) { + compactionModels = []; + return; + } + const data = (await res.json()) as { models: string[] }; + compactionModels = data.models ?? []; + } catch { + compactionModels = []; + } finally { + loadingCompactionModels = false; + } +} + +function saveCompactionModel(): void { + fetch(`${apiBase}/tabs/settings/compaction-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }), + }).catch(() => {}); +} + +async function onCompactionKeyChange(e: Event): Promise<void> { + const select = e.target as HTMLSelectElement; + compactionKeyId = select.value || null; + compactionModelId = null; + compactionModels = []; + if (compactionKeyId) { + await loadCompactionModelsForKey(compactionKeyId); + } + saveCompactionModel(); +} + +function onCompactionModelChange(e: Event): void { + const select = e.target as HTMLSelectElement; + compactionModelId = select.value || null; + saveCompactionModel(); +} + $effect(() => { void loadSettings(); }); @@ -374,6 +433,36 @@ $effect(() => { <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Conversation Compaction Model</p> + <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p> + + <label class="text-xs text-base-content/60"> + Key + <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}> + <option value="">Select a key...</option> + {#each keys as key (key.id)} + <option value={key.id}>{key.id} ({key.provider})</option> + {/each} + </select> + </label> + + <label class="text-xs text-base-content/60"> + Model + <select + class="select select-bordered select-sm w-full" + onchange={onCompactionModelChange} + value={compactionModelId ?? ""} + disabled={!compactionKeyId || loadingCompactionModels} + > + <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option> + {#each compactionModels as model (model)} + <option value={model}>{model}</option> + {/each} + </select> + </label> + + <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Chat</p> <label class="flex items-center gap-2 cursor-pointer"> <input diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index ff8556a..2003856 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -44,6 +44,9 @@ const { onReasoningChange, onAgentChange = (_agent: AgentInfo | null) => {}, onWorkingDirectoryChange = (_dir: string | null) => {}, + onCompact = () => {}, + canCompact = false, + compacting = false, onAddKey = () => {}, }: { keys?: KeyInfo[]; @@ -66,6 +69,9 @@ const { onReasoningChange: (effort: string) => void; onAgentChange?: (agent: AgentInfo | null) => void; onWorkingDirectoryChange?: (dir: string | null) => void; + onCompact?: () => void; + canCompact?: boolean; + compacting?: boolean; onAddKey?: () => void; } = $props(); @@ -172,6 +178,9 @@ function contentClass(_selected: string): string { {onAgentChange} {workingDirectory} {onWorkingDirectoryChange} + {onCompact} + {canCompact} + {compacting} /> {:else if panel.selected === "Key Usage"} <KeyUsage {keys} {apiBase} /> |
