summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md43
-rw-r--r--.dispatch/wire.reference.md23
-rw-r--r--ROADMAP.md1
-rw-r--r--backend-handoff.md21
-rw-r--r--src/adapters/ws/logic.ts2
-rw-r--r--src/app/App.svelte49
-rw-r--r--src/app/store.svelte.ts125
-rw-r--r--src/core/wire/conformance.test.ts1
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/ui/CompactionView.svelte153
10 files changed, 407 insertions, 13 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index e599eb3..608211d 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,10 +5,18 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
> this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation lifecycle).
-> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
+> **Orchestrator:** SNAPSHOT of `[email protected]` (compaction).
+> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
> `ui-contract.reference.md`).
>
+> **2026-06-22 delta (compaction handoff — package bumped `0.14.0` → `0.15.0`, ADDITIVE):**
+> adds conversation compaction — summarize old history + retain recent N messages. Manual:
+> `POST /conversations/:id/compact` (optional `{ keepLastN, modelName }`) → `CompactResponse`.
+> Automatic: after each turn settles, if the last turn's input tokens exceeded the per-conversation
+> `compactThreshold`, compaction runs automatically. `GET`/`PUT /conversations/:id/compact-threshold`
+> (`CompactThresholdResponse`/`SetCompactThresholdRequest`) — `threshold: 0` = disabled; default
+> 350000 when not stored. Re-exports `CompactionResult` from `[email protected]`.
+>
> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.13.0` → `0.14.0`, ADDITIVE):**
> adds conversation lifecycle **status** (`active`/`idle`/`closed`) for cross-device tab
> persistence. `ConversationMeta` (re-exported from `[email protected]`) gains a `status` field. New
@@ -712,6 +720,7 @@ export interface ConversationStatusChangedMessage {
export interface ConversationCompactedMessage {
readonly type: "conversation.compacted";
readonly conversationId: string;
+ readonly newConversationId: string;
readonly messagesSummarized: number;
readonly messagesKept: number;
}
@@ -760,4 +769,34 @@ export interface TitleResponse {
readonly conversationId: string;
readonly title: string;
}
+
+// ─── Compaction ──────────────────────────────────────────────────────────────
+
+/**
+ * Response for `POST /conversations/:id/compact` — confirms the conversation
+ * history was compacted (old messages summarized, recent messages retained).
+ */
+export interface CompactResponse {
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+/**
+ * Response for `GET /conversations/:id/compact-threshold` — the token count
+ * at which automatic compaction triggers (0 = manual only; default 350000
+ * when not stored).
+ */
+export interface CompactThresholdResponse {
+ readonly conversationId: string;
+ readonly threshold: number;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/compact-threshold`.
+ */
+export interface SetCompactThresholdRequest {
+ readonly threshold: number;
+}
```
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index ead4d9c..44b0fe7 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,9 +4,14 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation lifecycle status). Regenerate
+> **Orchestrator:** SNAPSHOT of `[email protected]` (compaction). Regenerate
> whenever `@dispatch/wire` changes.
>
+> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):**
+> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`,
+> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it
+> becomes the conversation's first system message after compaction).
+>
> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):**
> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle
> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` =
@@ -612,5 +617,21 @@ export interface ConversationMeta {
readonly lastActivityAt: number;
readonly title: string;
readonly status: ConversationStatus;
+ /** Points to the archive conversation with full pre-compaction history. */
+ readonly compactedFrom?: string;
+}
+
+// ─── Compaction ──────────────────────────────────────────────────────────────
+
+/**
+ * Result of a compaction operation. `summary` is the text the model produced;
+ * `messagesKept` is how many recent messages were retained after the summary;
+ * `messagesSummarized` is how many old messages were replaced by the summary.
+ */
+export interface CompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
```
diff --git a/ROADMAP.md b/ROADMAP.md
index c1c5c30..af9f8a4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -18,6 +18,7 @@
- **Todo task list** — `rendererId: "todo"` custom renderer, dedicated "Tasks" sidebar view (status indicators: pending/in_progress/completed/cancelled).
- **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag.
- **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations.
+- **Conversation compaction** — "Compaction" sidebar view with manual "Compact now" button (`POST /conversations/:id/compact`) + auto-compact threshold input (`GET`/`PUT /conversations/:id/compact-threshold`); `conversation.compacted` WS handler reloads history.
## Next up
diff --git a/backend-handoff.md b/backend-handoff.md
index ff4b961..382797a 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,21 +5,30 @@
> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-22 (conversation lifecycle handoff consumed). **FE is current on
-`[email protected]` / `[email protected]` / `[email protected]`.** All handoffs to date are
+_Last updated: 2026-06-22 (compaction handoff consumed). **FE is current on
+`[email protected]` / `[email protected]` / `[email protected]`.** All handoffs to date are
consumed: surfaces + WS, conversation transcript/metrics, tabs + model selector, cache-warming
(incl. authoritative timer + retention + cache-rate fix + the CR-4 lifecycle below),
**per-conversation cwd + LSP status**, **context size**, **turn continuity + multi-client live
view**, the **chat limit + CR-5 history windowing**, the **reasoning effort
(thinking-depth knob)**, the **message queue + steering**, the **todo task list**, the
-**conversation.open broadcast**, and the **conversation lifecycle (cross-device tab sync)**
-(below).
+**conversation.open broadcast**, the **conversation lifecycle (cross-device tab sync)**, and the
+**conversation compaction** (below).
**Open asks: NONE.** CR-1/CR-2/CR-4/CR-5 all RESOLVED ✅ (see §2); §3 lists likely next asks.
**CR-3 (watcher couldn't see the USER prompt until seal) → RESOLVED ✅** — backend shipped the
`user-message` turn event; FE re-pinned + consumption live.
The cwd/LSP draft-path verification (`backend-handoff-cwd-lsp.md`) came back **all ✅ confirmed**._
-**Conversation lifecycle handoff (`frontend-conversation-lifecycle-handoff.md`) → CONSUMED ✅.**
+**Compaction handoff (`frontend-compaction-handoff.md`) → CONSUMED ✅.**
+Re-pinned `[email protected]→0.11.0` + `[email protected]→0.15.0` (`ui-contract` unchanged);
+re-mirrored both `.dispatch/*.reference.md`. FE work: a dedicated "Compaction" sidebar view
+(`features/chat/ui/CompactionView.svelte`) with a "Compact now" button (`POST
+/conversations/:id/compact`) + an auto-compact threshold number input (`GET`/`PUT
+/conversations/:id/compact-threshold`; 0 = disabled, default 350000). The app store exposes
+`compactNow()` + `compactThreshold` reactive state + `setCompactThreshold()`, seeded on focus
+change (like reasoning-effort + cwd). The `conversation.compacted` WS handler (already wired in
+the lifecycle commit) disposes the stale store + cache + reloads history. 686 tests green. NO
+new backend ask._
Re-pinned `[email protected]→0.10.0` + `[email protected]→0.14.0` (`ui-contract` unchanged);
re-mirrored both `.dispatch/*.reference.md`. FE work: `fetchOpenConversations()` on connect fetches
`GET /conversations?status=active,idle` to restore the tab bar across devices (merges with
@@ -127,7 +136,7 @@ backend ask — but the max-limit denominator is now a live FE need; see §3.
## 1. Pinned backend contracts (consumed by the FE)
-Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
+Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
| Package | Used for |
|---|---|
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 53955f8..b11c5c4 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -136,11 +136,13 @@ export function parseServerMessage(data: string): WsServerMessage | null {
}
case "conversation.compacted": {
if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.newConversationId !== "string") return null;
if (typeof parsed.messagesSummarized !== "number") return null;
if (typeof parsed.messagesKept !== "number") return null;
const msg: ConversationCompactedMessage = {
type: "conversation.compacted",
conversationId: parsed.conversationId,
+ newConversationId: parsed.newConversationId,
messagesSummarized: parsed.messagesSummarized,
messagesKept: parsed.messagesKept,
};
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);
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index 58cba3a..c50cbf4 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -148,6 +148,7 @@ describe("classifies every WsServerMessage type", () => {
{
type: "conversation.compacted" as const,
conversationId: "c1",
+ newConversationId: "c2",
messagesSummarized: 10,
messagesKept: 5,
},
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 9b94392..9c65cd4 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -17,6 +17,8 @@ export {
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
+export type { CompactNowResult, SaveCompactThresholdResult } from "./ui/CompactionView.svelte";
+export { default as CompactionView } from "./ui/CompactionView.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
new file mode 100644
index 0000000..ce2a0a0
--- /dev/null
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -0,0 +1,153 @@
+<script lang="ts">
+ export type CompactNowResult =
+ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
+ | { readonly ok: false; readonly error: string };
+
+ export type SaveCompactThresholdResult =
+ | { readonly ok: true; readonly threshold: number }
+ | { readonly ok: false; readonly error: string };
+
+ let {
+ threshold,
+ canCompact,
+ compactNow,
+ saveThreshold,
+ }: {
+ /** The conversation's auto-compact threshold, or null when not yet fetched. 0 = disabled. */
+ threshold: number | null;
+ /** Whether a real conversation is focused (a draft has nothing to compact). */
+ canCompact: boolean;
+ compactNow: () => Promise<CompactNowResult | null>;
+ saveThreshold: (threshold: number) => Promise<SaveCompactThresholdResult | null>;
+ } = $props();
+
+ const DEFAULT_THRESHOLD = 350000;
+
+ let compacting = $state(false);
+ let compactError = $state<string | null>(null);
+ let compactResult = $state<{ summarized: number; kept: number } | null>(null);
+
+ let thresholdInput = $state("");
+ let savingThreshold = $state(false);
+ let thresholdError = $state<string | null>(null);
+ let thresholdSaved = $state(false);
+
+ // Sync the input from the prop when it changes (focus switch / initial load).
+ let lastThreshold = $state<number | null>(null);
+ $effect(() => {
+ if (threshold !== lastThreshold) {
+ lastThreshold = threshold;
+ thresholdInput = threshold !== null ? String(threshold) : "";
+ thresholdError = null;
+ thresholdSaved = false;
+ }
+ });
+
+ const thresholdLabel = $derived(
+ threshold == null
+ ? "Loading…"
+ : threshold === 0
+ ? "Disabled (manual only)"
+ : threshold === DEFAULT_THRESHOLD
+ ? `${threshold.toLocaleString("en-US")} (default)`
+ : threshold.toLocaleString("en-US"),
+ );
+
+ async function handleCompact() {
+ if (compacting || !canCompact) return;
+ compacting = true;
+ compactError = null;
+ compactResult = null;
+ const result = await compactNow();
+ compacting = false;
+ if (result === null) return;
+ if (result.ok) {
+ compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
+ } else {
+ compactError = result.error;
+ }
+ }
+
+ async function handleSaveThreshold() {
+ const value = Number.parseInt(thresholdInput, 10);
+ if (Number.isNaN(value) || value < 0) {
+ thresholdError = "Must be a non-negative number";
+ return;
+ }
+ savingThreshold = true;
+ thresholdError = null;
+ thresholdSaved = false;
+ const result = await saveThreshold(value);
+ savingThreshold = false;
+ if (result === null) return;
+ if (result.ok) {
+ thresholdSaved = true;
+ } else {
+ thresholdError = result.error;
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ <!-- Manual compaction -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canCompact || compacting}
+ onclick={handleCompact}
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact now
+ {/if}
+ </button>
+ {#if !canCompact}
+ <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
+ {:else if compactError}
+ <p class="text-xs text-error">{compactError}</p>
+ {:else if compactResult}
+ <p class="text-xs text-success">
+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
+ </p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Summarizes old messages into a system summary + retains the most recent messages.
+ </p>
+ {/if}
+ </section>
+
+ <!-- Auto-compact threshold -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact threshold</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-32"
+ min="0"
+ placeholder={DEFAULT_THRESHOLD.toLocaleString("en-US")}
+ value={thresholdInput}
+ disabled={savingThreshold}
+ onchange={handleSaveThreshold}
+ aria-label="Compact threshold (tokens)"
+ />
+ <span class="text-xs opacity-60">tokens</span>
+ {#if savingThreshold}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {thresholdLabel}
+ <br />
+ 0 disables auto-compact. Default is {DEFAULT_THRESHOLD.toLocaleString("en-US")}.
+ </p>
+ {#if thresholdError}
+ <p class="text-xs text-error">{thresholdError}</p>
+ {:else if thresholdSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+</div>