summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 15:14:31 +0900
committerAdam Malczewski <[email protected]>2026-06-22 15:14:31 +0900
commit0c7e7ceae36930e87fc30993f18e30cf54888295 (patch)
treedeae0cde3105e6cd22c1cd13f59b5524edad611c /src
parentb7ea4b7325c02bf29046ab232411c053b36a99bd (diff)
downloaddispatch-web-0c7e7ceae36930e87fc30993f18e30cf54888295.tar.gz
dispatch-web-0c7e7ceae36930e87fc30993f18e30cf54888295.zip
feat: consume context window + percentage-based compact handoff
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.
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte19
-rw-r--r--src/app/store.svelte.ts77
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/ui/CompactionView.svelte101
-rw-r--r--src/features/chat/ui/Composer.svelte10
5 files changed, 111 insertions, 98 deletions
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<SaveCompactThresholdResult | null> {
- const result = await store.setCompactThreshold(threshold);
+ async function saveCompactPercent(
+ percent: number,
+ ): Promise<SaveCompactPercentResult | null> {
+ 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"}
- <!-- Re-mount per conversation so the threshold + feedback can't bleed across tabs. -->
+ <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. -->
{#key store.currentConversationId}
<CompactionView
- threshold={store.compactThreshold}
+ percent={store.compactPercent}
canCompact={store.activeConversationId !== null}
{compactNow}
- saveThreshold={saveCompactThreshold}
+ savePercent={saveCompactPercent}
/>
{/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<Record<string, ModelMetadata>>;
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<CompactThresholdResult | null>;
+ setCompactPercent(percent: number): Promise<CompactPercentResult | 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.
@@ -233,6 +237,7 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let protocol = $state<ProtocolState>(protocolInitialState());
let models = $state<readonly string[]>([]);
+ let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
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<number | null>(null);
+ let compactPercent = $state<number | null>(null);
- /** Refetch the workspace conversation's compact threshold (works for a draft too). */
- async function refreshCompactThreshold(): Promise<void> {
+ /** Refetch the workspace conversation's compact percent (works for a draft too). */
+ async function refreshCompactPercent(): Promise<void> {
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<Record<string, ModelMetadata>> {
+ 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<CompactThresholdResult | null> {
+ async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
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<CompactNowResult | null>;
- saveThreshold: (threshold: number) => Promise<SaveCompactThresholdResult | null>;
+ savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
} = $props();
- const DEFAULT_THRESHOLD = 350000;
+ const DEFAULT_PERCENT = 85;
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);
+ let percentInput = $state("");
+ let savingPercent = $state(false);
+ let percentError = $state<string | null>(null);
+ let percentSaved = $state(false);
// Sync the input from the prop when it changes (focus switch / initial load).
- let lastThreshold = $state<number | null>(null);
+ let lastPercent = $state<number | null>(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;
}
}
</script>
@@ -120,33 +120,34 @@
{/if}
</section>
- <!-- Auto-compact threshold -->
+ <!-- Auto-compact percent -->
<section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Auto-compact threshold</span>
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
<div class="flex items-center gap-2">
<input
type="number"
- class="input input-bordered input-sm w-32"
+ class="input input-bordered input-sm w-24"
min="0"
- placeholder={DEFAULT_THRESHOLD.toLocaleString("en-US")}
- value={thresholdInput}
- disabled={savingThreshold}
- onchange={handleSaveThreshold}
- aria-label="Compact threshold (tokens)"
+ max="100"
+ placeholder={String(DEFAULT_PERCENT)}
+ value={percentInput}
+ disabled={savingPercent}
+ onchange={handleSavePercent}
+ aria-label="Compact percent (0-100)"
/>
- <span class="text-xs opacity-60">tokens</span>
- {#if savingThreshold}
+ <span class="text-xs opacity-60">%</span>
+ {#if savingPercent}
<span class="loading loading-spinner loading-xs"></span>
{/if}
</div>
<p class="text-xs opacity-50">
- Current: {thresholdLabel}
+ Current: {percentLabel}
<br />
- 0 disables auto-compact. Default is {DEFAULT_THRESHOLD.toLocaleString("en-US")}.
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
</p>
- {#if thresholdError}
- <p class="text-xs text-error">{thresholdError}</p>
- {:else if thresholdSaved}
+ {#if percentError}
+ <p class="text-xs text-error">{percentError}</p>
+ {:else if percentSaved}
<p class="text-xs text-success">Saved.</p>
{/if}
</section>
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 @@
<script lang="ts">
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
- // Placeholder context-window limit until the backend reports a real
- // per-model max (see backend-handoff §3). Hardcoded to 1,000,000 tokens.
- const MAX_CONTEXT = 1_000_000;
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
const MAX_LINES = 7;
let {
@@ -11,6 +9,7 @@
onQueue,
onStop,
contextSize = undefined,
+ contextWindow = undefined,
status = "idle",
}: {
onSend: (text: string) => void;
@@ -26,6 +25,8 @@
// Current context occupancy (latest turn's contextSize), or `undefined`
// when unknown — the status bar then shows "— tokens", never 0%.
contextSize?: number | undefined;
+ /** Per-model context window (max tokens) from `GET /models` modelInfo. */
+ contextWindow?: number | undefined;
// Coarse agent status for the status-bar icon.
status?: "idle" | "running" | "error";
} = $props();
@@ -34,7 +35,8 @@
let inputEl: HTMLTextAreaElement | undefined;
const hasText = $derived(text.trim().length > 0);
- const usage = $derived(computeContextUsage(contextSize, MAX_CONTEXT));
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
const hasUsage = $derived(contextSize !== undefined);
// One button, three modes: