diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 01:31:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 01:31:29 +0900 |
| commit | 2772e0723cfc7898443320515e165a625de1db46 (patch) | |
| tree | c65e8a7c1a9ffb1ca6b44147cd3eb8629aa47830 /src/features/chat/ui/CompactionView.svelte | |
| parent | 54e88b71efd9a6fd9d880b6e90d844a875808662 (diff) | |
| download | dispatch-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.svelte | 153 |
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> |
