From 0c7e7ceae36930e87fc30993f18e30cf54888295 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 22 Jun 2026 15:14:31 +0900 Subject: feat: consume context window + percentage-based compact handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Real context window: GET /models now returns modelInfo[model].contextWindow. The Composer uses this instead of the hardcoded MAX_CONTEXT = 1,000,000. Falls back to 1M when modelInfo is absent or the model has no contextWindow. 2. Percentage-based auto-compact: the compact-threshold endpoint is renamed to compact-percent. The CompactionView now shows a percent input (0-100, default 85, 0 = manual) instead of a token count input. Types renamed: CompactThresholdResponse → CompactPercentResponse, SetCompactThresholdRequest → SetCompactPercentRequest. Note: the field name in the backend types is still 'threshold' (not 'percent') — the FE maps between them. Re-mirrored .dispatch/transport-contract.reference.md. 686 tests green. 0 svelte-check errors + warnings. --- src/app/App.svelte | 19 +++--- src/app/store.svelte.ts | 77 ++++++++++++---------- src/features/chat/index.ts | 2 +- src/features/chat/ui/CompactionView.svelte | 101 +++++++++++++++-------------- src/features/chat/ui/Composer.svelte | 10 +-- 5 files changed, 111 insertions(+), 98 deletions(-) (limited to 'src') diff --git a/src/app/App.svelte b/src/app/App.svelte index 57fe16f..ae09bd5 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -17,7 +17,7 @@ ReasoningEffortSelector, type CompactNowResult, type ReasoningEffortSaveResult, - type SaveCompactThresholdResult, + type SaveCompactPercentResult, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; @@ -249,13 +249,13 @@ : { ok: false, error: result.error }; } - async function saveCompactThreshold( - threshold: number, - ): Promise { - const result = await store.setCompactThreshold(threshold); + async function saveCompactPercent( + percent: number, + ): Promise { + const result = await store.setCompactPercent(percent); if (result === null) return null; return result.ok - ? { ok: true, threshold: result.threshold } + ? { ok: true, percent: result.percent } : { ok: false, error: result.error }; } @@ -393,6 +393,7 @@ onQueue={handleQueue} onStop={handleStop} contextSize={store.activeChat.currentContextSize} + contextWindow={store.modelInfo[store.activeModel]?.contextWindow} status={store.activeChat.error ? "error" : store.activeChat.generating @@ -482,13 +483,13 @@ {/if} {/key} {:else if kind === "compaction"} - + {#key store.currentConversationId} {/key} {:else if kind === "settings"} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index bb08585..3f78a97 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,8 +1,8 @@ import type { ChatDeltaMessage, ChatErrorMessage, + CompactPercentResponse, CompactResponse, - CompactThresholdResponse, ConversationCompactedMessage, ConversationHistoryResponse, ConversationListResponse, @@ -11,10 +11,11 @@ import type { ConversationStatusChangedMessage, CwdResponse, LspStatusResponse, + ModelMetadata, ModelsResponse, ReasoningEffort, ReasoningEffortResponse, - SetCompactThresholdRequest, + SetCompactPercentRequest, SetCwdRequest, SetReasoningEffortRequest, WarmRequest, @@ -73,9 +74,9 @@ 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 } +/** Outcome of `PUT /conversations/:id/compact-percent`. */ +export type CompactPercentResult = + | { readonly ok: true; readonly percent: number } | { readonly ok: false; readonly error: string }; /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ @@ -88,6 +89,8 @@ export interface AppStore { readonly activeConversationId: string | null; readonly activeChat: ChatStore; readonly models: readonly string[]; + /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ + readonly modelInfo: Readonly>; readonly activeModel: string; readonly catalog: ProtocolState["catalog"]; /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ @@ -152,17 +155,18 @@ export interface AppStore { */ stopGeneration(): void; /** - * The workspace conversation's auto-compact threshold (tokens). `0` = disabled + * The workspace conversation's auto-compact percent (0-100). `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; + readonly compactPercent: number | null; /** - * Persist the workspace conversation's auto-compact threshold - * (`PUT /conversations/:id/compact-threshold`). `0` disables; any positive + * Persist the workspace conversation's auto-compact percent + * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the + * trigger percentage of the model's context window. Default (null) is 85. * number enables. Works for a draft too (its id survives promotion). */ - setCompactThreshold(threshold: number): Promise; + setCompactPercent(percent: number): Promise; /** * 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. @@ -233,6 +237,7 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state(protocolInitialState()); let models = $state([]); + let modelInfo = $state>>({}); let activeModel = $state(DEFAULT_MODEL); const wsLocation = typeof location !== "undefined" ? location : undefined; @@ -358,23 +363,23 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } - // The workspace conversation's auto-compact threshold. Seeded from the + // The workspace conversation's auto-compact percent. Seeded from the // backend on focus change; null = not yet fetched. 0 = disabled. - let compactThreshold = $state(null); + let compactPercent = $state(null); - /** Refetch the workspace conversation's compact threshold (works for a draft too). */ - async function refreshCompactThreshold(): Promise { + /** Refetch the workspace conversation's compact percent (works for a draft too). */ + async function refreshCompactPercent(): Promise { const id = workspaceConversationId(); - compactThreshold = null; + compactPercent = null; try { const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-threshold`, + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, ); if (!res.ok) return; - const data = (await res.json()) as CompactThresholdResponse; - if (workspaceConversationId() === id) compactThreshold = data.threshold; + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; } catch { - // Non-fatal: a threshold fetch failure just leaves null. + // Non-fatal: a percent fetch failure just leaves null. } } @@ -542,7 +547,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); - void refreshCompactThreshold(); + void refreshCompactPercent(); } // Conversation lifecycle status (backend-owned, pushed via WS + @@ -676,6 +681,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { .then((data) => { if (data === undefined) return; models = data.models; + modelInfo = data.modelInfo ?? {}; if (data.models.length > 0 && !data.models.includes(activeModel)) { const first = data.models[0]; if (first !== undefined) { @@ -713,7 +719,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); void refreshCwd(); void refreshReasoningEffort(); - void refreshCompactThreshold(); + void refreshCompactPercent(); // Fetch the authoritative open-conversation list from the backend (cross- // device tab sync). Merges with the localStorage-restored tabs: opens new @@ -733,6 +739,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get models(): readonly string[] { return models; }, + get modelInfo(): Readonly> { + return modelInfo; + }, get activeModel(): string { return activeModel; }, @@ -759,8 +768,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get reasoningEffort(): ReasoningEffort | null { return reasoningEffort; }, - get compactThreshold(): number | null { - return compactThreshold; + get compactPercent(): number | null { + return compactPercent; }, get chatLimit(): number { return chatLimit; @@ -800,7 +809,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); - void refreshCompactThreshold(); + void refreshCompactPercent(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -837,7 +846,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); - void refreshCompactThreshold(); + void refreshCompactPercent(); }, selectTab(conversationId: string): void { @@ -850,7 +859,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); - void refreshCompactThreshold(); + void refreshCompactPercent(); }, closeTab(conversationId: string): void { @@ -988,12 +997,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, - async setCompactThreshold(threshold: number): Promise { + async setCompactPercent(percent: number): Promise { const id = workspaceConversationId(); - const body: SetCompactThresholdRequest = { threshold }; + const body: SetCompactPercentRequest = { threshold: percent }; try { const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-threshold`, + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, { method: "PUT", headers: { "content-type": "application/json" }, @@ -1004,16 +1013,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, - error: errBody?.error ?? `Set compact threshold failed (HTTP ${res.status})`, + error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, }; } - const data = (await res.json()) as CompactThresholdResponse; - if (workspaceConversationId() === id) compactThreshold = data.threshold; - return { ok: true, threshold: data.threshold }; + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; + return { ok: true, percent: data.threshold }; } catch (err) { return { ok: false, - error: err instanceof Error ? err.message : "Set compact threshold request failed", + error: err instanceof Error ? err.message : "Set compact percent request failed", }; } }, diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 9c65cd4..1596c53 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -17,7 +17,7 @@ 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 type { CompactNowResult, SaveCompactPercentResult } 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"; diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte index ce2a0a0..7bec984 100644 --- a/src/features/chat/ui/CompactionView.svelte +++ b/src/features/chat/ui/CompactionView.svelte @@ -3,54 +3,54 @@ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } | { readonly ok: false; readonly error: string }; - export type SaveCompactThresholdResult = - | { readonly ok: true; readonly threshold: number } + export type SaveCompactPercentResult = + | { readonly ok: true; readonly percent: number } | { readonly ok: false; readonly error: string }; let { - threshold, + percent, canCompact, compactNow, - saveThreshold, + savePercent, }: { - /** The conversation's auto-compact threshold, or null when not yet fetched. 0 = disabled. */ - threshold: number | null; + /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ + percent: number | null; /** Whether a real conversation is focused (a draft has nothing to compact). */ canCompact: boolean; compactNow: () => Promise; - saveThreshold: (threshold: number) => Promise; + savePercent: (percent: number) => Promise; } = $props(); - const DEFAULT_THRESHOLD = 350000; + const DEFAULT_PERCENT = 85; let compacting = $state(false); let compactError = $state(null); let compactResult = $state<{ summarized: number; kept: number } | null>(null); - let thresholdInput = $state(""); - let savingThreshold = $state(false); - let thresholdError = $state(null); - let thresholdSaved = $state(false); + let percentInput = $state(""); + let savingPercent = $state(false); + let percentError = $state(null); + let percentSaved = $state(false); // Sync the input from the prop when it changes (focus switch / initial load). - let lastThreshold = $state(null); + let lastPercent = $state(null); $effect(() => { - if (threshold !== lastThreshold) { - lastThreshold = threshold; - thresholdInput = threshold !== null ? String(threshold) : ""; - thresholdError = null; - thresholdSaved = false; + if (percent !== lastPercent) { + lastPercent = percent; + percentInput = percent !== null ? String(percent) : ""; + percentError = null; + percentSaved = false; } }); - const thresholdLabel = $derived( - threshold == null + const percentLabel = $derived( + percent == null ? "Loading…" - : threshold === 0 + : percent === 0 ? "Disabled (manual only)" - : threshold === DEFAULT_THRESHOLD - ? `${threshold.toLocaleString("en-US")} (default)` - : threshold.toLocaleString("en-US"), + : percent === DEFAULT_PERCENT + ? `${percent}% (default)` + : `${percent}%`, ); async function handleCompact() { @@ -68,22 +68,22 @@ } } - async function handleSaveThreshold() { - const value = Number.parseInt(thresholdInput, 10); - if (Number.isNaN(value) || value < 0) { - thresholdError = "Must be a non-negative number"; + async function handleSavePercent() { + const value = Number.parseInt(percentInput, 10); + if (Number.isNaN(value) || value < 0 || value > 100) { + percentError = "Must be 0-100"; return; } - savingThreshold = true; - thresholdError = null; - thresholdSaved = false; - const result = await saveThreshold(value); - savingThreshold = false; + savingPercent = true; + percentError = null; + percentSaved = false; + const result = await savePercent(value); + savingPercent = false; if (result === null) return; if (result.ok) { - thresholdSaved = true; + percentSaved = true; } else { - thresholdError = result.error; + percentError = result.error; } } @@ -120,33 +120,34 @@ {/if} - +
- Auto-compact threshold + Auto-compact percent
- tokens - {#if savingThreshold} + % + {#if savingPercent} {/if}

- Current: {thresholdLabel} + Current: {percentLabel}
- 0 disables auto-compact. Default is {DEFAULT_THRESHOLD.toLocaleString("en-US")}. + 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.

- {#if thresholdError} -

{thresholdError}

- {:else if thresholdSaved} + {#if percentError} +

{percentError}

+ {:else if percentSaved}

Saved.

{/if}
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 7030153..fe9ea94 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,9 +1,7 @@