From 2772e0723cfc7898443320515e165a625de1db46 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Mon, 22 Jun 2026 01:31:29 +0900
Subject: feat(compaction): conversation compacting + auto-compact threshold
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Consume the compaction handoff (wire@0.11.0, transport-contract@0.15.0).
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.
---
 src/features/chat/ui/CompactionView.svelte | 153 +++++++++++++++++++++++++++++
 1 file changed, 153 insertions(+)
 create mode 100644 src/features/chat/ui/CompactionView.svelte

(limited to 'src/features/chat/ui/CompactionView.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>
-- 
cgit v1.2.3

