diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 43 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 23 | ||||
| -rw-r--r-- | ROADMAP.md | 1 | ||||
| -rw-r--r-- | backend-handoff.md | 21 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 2 | ||||
| -rw-r--r-- | src/app/App.svelte | 49 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 125 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 2 | ||||
| -rw-r--r-- | src/features/chat/ui/CompactionView.svelte | 153 |
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; } ``` @@ -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 +_Last updated: 2026-06-22 (compaction handoff consumed). **FE is current on 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) | 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> |
