summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 01:31:29 +0900
committerAdam Malczewski <[email protected]>2026-06-22 01:31:29 +0900
commit2772e0723cfc7898443320515e165a625de1db46 (patch)
treec65e8a7c1a9ffb1ca6b44147cd3eb8629aa47830 /src/app
parent54e88b71efd9a6fd9d880b6e90d844a875808662 (diff)
downloaddispatch-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.svelte49
-rw-r--r--src/app/store.svelte.ts125
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);