summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/CompactionView.svelte
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/features/chat/ui/CompactionView.svelte
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/features/chat/ui/CompactionView.svelte')
-rw-r--r--src/features/chat/ui/CompactionView.svelte153
1 files changed, 153 insertions, 0 deletions
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>