diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 01:31:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 01:31:29 +0900 |
| commit | 2772e0723cfc7898443320515e165a625de1db46 (patch) | |
| tree | c65e8a7c1a9ffb1ca6b44147cd3eb8629aa47830 /src/app | |
| parent | 54e88b71efd9a6fd9d880b6e90d844a875808662 (diff) | |
| download | dispatch-web-2772e0723cfc7898443320515e165a625de1db46.tar.gz dispatch-web-2772e0723cfc7898443320515e165a625de1db46.zip | |
feat(compaction): conversation compacting + auto-compact threshold
Consume the compaction handoff ([email protected], [email protected]).
Re-pinned file: deps + re-mirrored .dispatch/*.reference.md.
- New 'Compaction' sidebar view (CompactionView.svelte):
- 'Compact now' button → POST /conversations/:id/compact (loading indicator
+ result: 'N messages summarized, M kept')
- Auto-compact threshold number input → GET/PUT
/conversations/:id/compact-threshold (0 = disabled, default 350000)
- Re-mounts per conversation via {#key}
- App store: compactNow() + compactThreshold reactive state +
setCompactThreshold(), seeded on focus change (like reasoning-effort + cwd)
- conversation.compacted WS handler: reloads the SAME conversation's history
(ID unchanged — old history forked to an archive, not a tab switch)
- WS adapter parses newConversationId field on ConversationCompactedMessage
- conformance guards + tests cover the new type
686 tests green.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 49 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 125 |
2 files changed, 170 insertions, 4 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index e065759..350db49 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -10,11 +10,14 @@ } from "../features/cache-warming"; import { ChatView, + CompactionView, Composer, manifest as chatManifest, ModelSelector, ReasoningEffortSelector, + type CompactNowResult, type ReasoningEffortSaveResult, + type SaveCompactThresholdResult, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; @@ -65,11 +68,20 @@ { id: "extensions", label: "Extensions" }, { id: "cache-warming", label: "Cache Warming" }, { id: "tasks", label: "Tasks" }, + { id: "compaction", label: "Compaction" }, { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: Model, Language Servers, Extensions, Cache Warming, Tasks, Settings. - const initialViews = ["model", "lsp", "extensions", "cache-warming", "tasks", "settings"] as const; + // Default sidebar layout: Model, Language Servers, Extensions, Cache Warming, Tasks, Compaction, Settings. + const initialViews = [ + "model", + "lsp", + "extensions", + "cache-warming", + "tasks", + "compaction", + "settings", + ] as const; // 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 @@ -211,6 +223,29 @@ : { ok: false, error: result.error }; } + // 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 saveCompactThreshold( + threshold: number, + ): Promise<SaveCompactThresholdResult | null> { + const result = await store.setCompactThreshold(threshold); + if (result === null) return null; + return result.ok + ? { ok: true, threshold: result.threshold } + : { 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 @@ -426,6 +461,16 @@ <p class="text-xs opacity-60">No tasks yet.</p> {/if} {/key} + {:else if kind === "compaction"} + <!-- Re-mount per conversation so the threshold + feedback can't bleed across tabs. --> + {#key store.currentConversationId} + <CompactionView + threshold={store.compactThreshold} + canCompact={store.activeConversationId !== null} + {compactNow} + saveThreshold={saveCompactThreshold} + /> + {/key} {: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. --> diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 6fd8e5e..1359fcd 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,6 +1,8 @@ import type { ChatDeltaMessage, ChatErrorMessage, + CompactResponse, + CompactThresholdResponse, ConversationCompactedMessage, ConversationHistoryResponse, ConversationListResponse, @@ -12,6 +14,7 @@ import type { ModelsResponse, ReasoningEffort, ReasoningEffortResponse, + SetCompactThresholdRequest, SetCwdRequest, SetReasoningEffortRequest, WarmRequest, @@ -65,6 +68,16 @@ export type ReasoningEffortResult = | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } | { readonly ok: false; readonly error: string }; +/** Outcome of `POST /conversations/:id/compact` (manual compaction). */ +export type CompactResult = + | { readonly ok: true; readonly response: CompactResponse } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `PUT /conversations/:id/compact-threshold`. */ +export type CompactThresholdResult = + | { readonly ok: true; readonly threshold: number } + | { readonly ok: false; readonly error: string }; + /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ export type ChatLimitResult = | { readonly ok: true; readonly chatLimit: number } @@ -123,6 +136,24 @@ export interface AppStore { */ setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>; /** + * Manually trigger conversation compaction (`POST /conversations/:id/compact`). + * Summarizes old messages + retains the most recent N. Returns null when no + * conversation is focused (a draft has nothing to compact). + */ + compactNow(keepLastN?: number): Promise<CompactResult | null>; + /** + * The workspace conversation's auto-compact threshold (tokens). `0` = disabled + * (manual only); a positive number = auto-compact triggers when the last + * turn's input tokens exceed it. Seeded from the backend on focus change. + */ + readonly compactThreshold: number | null; + /** + * Persist the workspace conversation's auto-compact threshold + * (`PUT /conversations/:id/compact-threshold`). `0` disables; any positive + * number enables. Works for a draft too (its id survives promotion). + */ + setCompactThreshold(threshold: number): Promise<CompactThresholdResult | null>; + /** * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. */ @@ -317,6 +348,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // The workspace conversation's auto-compact threshold. Seeded from the + // backend on focus change; null = not yet fetched. 0 = disabled. + let compactThreshold = $state<number | null>(null); + + /** Refetch the workspace conversation's compact threshold (works for a draft too). */ + async function refreshCompactThreshold(): Promise<void> { + const id = workspaceConversationId(); + compactThreshold = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-threshold`, + ); + if (!res.ok) return; + const data = (await res.json()) as CompactThresholdResponse; + if (workspaceConversationId() === id) compactThreshold = data.threshold; + } catch { + // Non-fatal: a threshold fetch failure just leaves null. + } + } + function getActiveChat(): ChatStore { const activeId = tabsStore.activeConversationId; if (activeId === null) { @@ -481,6 +532,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); + void refreshCompactThreshold(); } // Conversation lifecycle status (backend-owned, pushed via WS + @@ -562,8 +614,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, onConversationCompacted(msg: ConversationCompactedMessage): void { - // The conversation's history was summarized — reload it from the server. - // Dispose the old store (stale cache) + create a fresh one + load. + // Compaction keeps the conversation ID — the old full history is forked + // to an archive (newConversationId). Just reload the same conversation's + // history (dispose stale store + cache + re-fetch). const cid = msg.conversationId; const wasActive = tabsStore.activeConversationId === cid; const store = chatStores.get(cid); @@ -650,6 +703,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); void refreshCwd(); void refreshReasoningEffort(); + void refreshCompactThreshold(); // Fetch the authoritative open-conversation list from the backend (cross- // device tab sync). Merges with the localStorage-restored tabs: opens new @@ -692,6 +746,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get reasoningEffort(): ReasoningEffort | null { return reasoningEffort; }, + get compactThreshold(): number | null { + return compactThreshold; + }, get chatLimit(): number { return chatLimit; }, @@ -730,6 +787,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); + void refreshCompactThreshold(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -766,6 +824,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); + void refreshCompactThreshold(); }, selectTab(conversationId: string): void { @@ -778,6 +837,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); + void refreshCompactThreshold(); }, closeTab(conversationId: string): void { @@ -874,6 +934,67 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async compactNow(keepLastN?: number): Promise<CompactResult | null> { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: Record<string, unknown> = {}; + if (keepLastN !== undefined) body.keepLastN = keepLastN; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactResponse; + return { ok: true, response: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Compact request failed", + }; + } + }, + + async setCompactThreshold(threshold: number): Promise<CompactThresholdResult | null> { + const id = workspaceConversationId(); + const body: SetCompactThresholdRequest = { threshold }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-threshold`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set compact threshold failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactThresholdResponse; + if (workspaceConversationId() === id) compactThreshold = data.threshold; + return { ok: true, threshold: data.threshold }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set compact threshold request failed", + }; + } + }, + async setChatLimit(limit: number): Promise<ChatLimitResult> { const next = normalizeChatLimit(limit); chatLimitStore.save(next); |
