From b26821ead97b986f886065b20d3dbde8283daa64 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 3 Jun 2026 01:26:16 +0900 Subject: feat(compaction): add UI-driven conversation compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summarize a conversation's older "head" into a structured anchored Markdown summary while preserving the most recent turns verbatim, shrinking context size while keeping the information needed to continue coherently. Triggered by a "Compact conversation" button in Chat Settings (not an agent tool). Approach informed by OpenCode's session/compaction.ts: - Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context / Relevant Files) and the anchored-summary buildPrompt (re-summarizes a prior summary when present). - Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary request. - Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2) instead of OpenCode's token-budget splitTurn. core: - New src/compaction/ module (pure, DB-free): template, prompt builder, head/tail selection, transcript renderer with tool-output capping, prior summary extraction. Generic over ChatMessage so callers keep turnId/seq. - db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a backup tab (reversible — nothing is deleted). - AgentEvent: compaction-started / -complete / -error variants. api: - AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free resolveConnection() for the compactor model (configured compaction_model_*, else the source tab's own key+model), one-shot tool-less summary generation via a transient Agent, then relocate full history to a fresh backup tab and re-seed the canonical source id with [summary turn + preserved tail]. Source tab is locked (messages queue) during the run; queue drains afterward. - Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model. frontend: - "Compact conversation" button in ModelSelector (Chat Settings), between Working Directory and the agent toggle; idle-gated. - Compaction-model key+model selector in Settings, beside the title model. - Transient placeholder tab shows a large, non-faded "Please wait, compacting conversation…" screen; closing it cancels. Source input locked while running. - Handle compaction-* events: reload compacted source, insert backup tab, refocus source, discard placeholder. tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab orchestration tests, and compaction route tests. All green (713 tests), biome clean, all typechecks pass, frontend builds. --- .../frontend/src/lib/components/ChatInput.svelte | 13 +- .../frontend/src/lib/components/ChatPanel.svelte | 27 ++- .../src/lib/components/ModelSelector.svelte | 24 +++ .../src/lib/components/SettingsPanel.svelte | 89 ++++++++++ .../src/lib/components/SidebarPanel.svelte | 9 + packages/frontend/src/lib/tabs.svelte.ts | 195 +++++++++++++++++++++ packages/frontend/src/lib/types.ts | 11 +- 7 files changed, 361 insertions(+), 7 deletions(-) (limited to 'packages/frontend/src/lib') diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 079ef4a..f954be8 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -17,6 +17,13 @@ const inputValue = $derived(tabStore.activeTab?.draft ?? ""); 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); // While generating with an empty box, the primary action is "stop". With text // in the box, it stays "send" (the message is queued behind the live turn). @@ -88,6 +95,7 @@ function handleKeydown(e: KeyboardEvent) { } function submit() { + if (compactLocked) return; const text = inputValue.trim(); if (!text) return; if (tabId) tabStore.setDraft(tabId, ""); @@ -110,7 +118,8 @@ function primaryAction() { bind:this={inputEl} value={inputValue} rows="1" - placeholder="Type a message..." + placeholder={compactLocked ? "Compaction in progress…" : "Type a message..."} + disabled={compactLocked} class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" onkeydown={handleKeydown} oninput={handleInput} @@ -120,7 +129,7 @@ function primaryAction() { + +