summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
commited3425cb8ed57862f954505831be38a2588729bd (patch)
tree518e613106fbe1c4dd1a0bbf786be4bae3cc1227 /src
parent061f44e0efecb9cf9fa817875517390d09655104 (diff)
parent9f2710f7686b0521c2503446437b3a45d42f6d66 (diff)
downloaddispatch-web-ed3425cb8ed57862f954505831be38a2588729bd.tar.gz
dispatch-web-ed3425cb8ed57862f954505831be38a2588729bd.zip
Merge branch 'feature/step-context-update' into predev
Diffstat (limited to 'src')
-rw-r--r--src/core/metrics/format.test.ts8
-rw-r--r--src/core/metrics/format.ts20
-rw-r--r--src/core/metrics/reducer.test.ts247
-rw-r--r--src/core/metrics/reducer.ts92
-rw-r--r--src/features/chat/store.svelte.ts9
-rw-r--r--src/features/chat/ui/Composer.svelte9
6 files changed, 364 insertions, 21 deletions
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts
index c7c4fbb..97170d0 100644
--- a/src/core/metrics/format.test.ts
+++ b/src/core/metrics/format.test.ts
@@ -351,8 +351,14 @@ describe("computeContextUsage", () => {
expect(u.percent).toBeCloseTo(3.4102, 4);
});
- it("treats unknown contextSize as current 0", () => {
+ it("treats unknown contextSize as current null (never 0)", () => {
const u = computeContextUsage(undefined, 1_000_000);
+ expect(u.current).toBeNull();
+ expect(u.percent).toBeNull();
+ });
+
+ it("an explicit 0 context size is a real reported value (current 0)", () => {
+ const u = computeContextUsage(0, 1_000_000);
expect(u.current).toBe(0);
expect(u.percent).toBe(0);
});
diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts
index 56e74e4..894bd54 100644
--- a/src/core/metrics/format.ts
+++ b/src/core/metrics/format.ts
@@ -45,14 +45,17 @@ export function formatCompactTokens(n: number): string {
/**
* Context-window occupancy: the current size against a max window limit.
*
- * `current` is the latest turn's context size (0 when unknown); `max` is the
- * model's window limit (or `null` when unknown). `percent` is
- * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the
- * precision) — so a few-thousand-token context against a 1,000,000 window still
- * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator).
+ * `current` is the latest turn's context size, or `null` when unknown (no
+ * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot
+ * silently render "0 tokens / 1M"; it must branch on `current === null` and show
+ * a placeholder instead. `max` is the model's window limit (or `null` when
+ * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED
+ * (the UI picks the precision) — so a few-thousand-token context against a
+ * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR
+ * `max` is unknown (no bar/denominator).
*/
export interface ContextUsage {
- readonly current: number;
+ readonly current: number | null;
readonly max: number | null;
readonly percent: number | null;
}
@@ -61,9 +64,10 @@ export function computeContextUsage(
contextSize: number | undefined,
contextLimit: number | null | undefined,
): ContextUsage {
- const current = contextSize ?? 0;
+ const current = contextSize ?? null;
const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
- const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100));
+ const percent =
+ current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100));
return { current, max, percent };
}
diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts
index 7d0a270..581a8b7 100644
--- a/src/core/metrics/reducer.test.ts
+++ b/src/core/metrics/reducer.test.ts
@@ -439,4 +439,251 @@ describe("contextSize / selectCurrentContextSize", () => {
]);
expect(selectCurrentContextSize(s)).toBe(222);
});
+
+ it("in-flight turn updates context size after the first step completes", () => {
+ // Before the requirement: an in-flight turn had total=null so its step usage
+ // was ignored until `done`. Now the latest step's input+output is used.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+
+ // Still generating (no done) — context = step 1 input+output = 5200.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("in-flight turn updates progressively as each step reports usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Step 2 reports usage mid-stream (before its step-complete): each step's
+ // input already includes all prior context, so the last step's input+output
+ // is the current occupancy.
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => {
+ // Mirrors the finalized-turn test: contextSize is the FINAL step's
+ // input+output, not the sum across steps (which would overcount a
+ // multi-step turn because every step re-prefills the growing prompt).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // Aggregate would be 300+130=430; the latest step is 200+80=280.
+ expect(selectCurrentContextSize(s)).toBe(280);
+ });
+
+ it("in-flight turn with a step-complete but no usage falls back to older turn", () => {
+ // step-complete before usage → the step has no usage yet, so the in-flight
+ // turn exposes no context size and the display falls back to the prior
+ // finalized turn's value (never 0).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 }));
+
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight turn with no steps/usage returns undefined (falls back)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 just started — no usage, no complete step — omitted entirely.
+ s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ expect(selectCurrentContextSize(s)).toBe(700);
+
+ // t2's first step reports usage → the display jumps to t2's live value.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("done finalizes the in-flight progressive value with the authoritative contextSize", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // done stamps the authoritative contextSize (the final step's input+output).
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 }));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => {
+ // cacheReadTokens / cacheWriteTokens are portions of inputTokens already
+ // counted — adding them would double-count. Only input+output is occupancy.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s1" as StepId,
+ usage: {
+ inputTokens: 5000,
+ outputTokens: 200,
+ cacheReadTokens: 4000,
+ cacheWriteTokens: 1000,
+ },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both).
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("multiple in-flight turns: the newest turn's live value wins", () => {
+ let s = initialMetricsState();
+ // t1 (older) in-flight with one completed step → 5200.
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // t2 (newer, seen later → last in liveOrder) in-flight → 8000.
+ s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(8000);
+ });
+
+ it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => {
+ // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is
+ // the newest step regardless of when each step's step-complete arrives.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ // Neither step complete yet → the turn is omitted (no complete step), so the
+ // display can't update until the first step completes.
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+
+ // s1 completes AFTER s2's usage was reported. The turn is now visible; the
+ // newest-first scan picks s2 (the later step), not s1 (the just-completed one).
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // s2 completes — still s2, unchanged.
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("done turn without contextSize falls back to an older turn (even with step usage)", () => {
+ // Contract lock-in: a done turn's step usage is NOT consulted for the
+ // context display — only its authoritative total.contextSize is. When that
+ // is absent, the display falls back to the next older finalized turn rather
+ // than synthesizing a value from the step usage.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 done WITH step usage but NO done.contextSize (edge case: the done event
+ // omitted contextSize despite per-step usage).
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => {
+ // A corrupt provider report must never reach the status bar. The newest
+ // step with invalid counters is skipped, falling back to the prior valid one.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // s2 reports NaN input (e.g. a non-numeric provider field coerced).
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s2" as StepId,
+ usage: { inputTokens: Number.NaN, outputTokens: 150 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Negative tokens are likewise skipped.
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s3" as StepId,
+ usage: { inputTokens: -10, outputTokens: 5 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+});
+
+describe("applyDurableMetrics pruning", () => {
+ it("prunes a live turn once durable data covers it (no unbounded growth)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ expect(s.live.has("t1")).toBe(true);
+ expect(s.liveOrder).toContain("t1");
+
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }],
+ contextSize: 150,
+ },
+ ]);
+ // The live copy is gone; the durable (authoritative) entry replaces it.
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.liveOrder).not.toContain("t1");
+ expect(s.durable.has("t1")).toBe(true);
+ // The display still reads the durable value atomically (no gap).
+ expect(selectCurrentContextSize(s)).toBe(150);
+ });
+
+ it("prunes only the turns present in the durable batch (leaves other live turns)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ // t2 still in flight — must NOT be pruned when only t1 seals.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 },
+ ]);
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.live.has("t2")).toBe(true);
+ expect(s.liveOrder).toEqual(["t2"]);
+ // The newest (in-flight) turn's live value still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("is a no-op when no incoming turn is live (no live mutation)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ const before = s;
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] },
+ ]);
+ // t1 was never live → the live map/order are unchanged (same reference).
+ expect(s.live).toBe(before.live);
+ expect(s.liveOrder).toBe(before.liveOrder);
+ // t1 (durable) is older; the in-flight t2 still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 }));
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 },
+ ]);
+ // The live (111) copy is pruned; only durable (222) remains.
+ expect(s.live.has("t1")).toBe(false);
+ expect(selectCurrentContextSize(s)).toBe(222);
+ });
});
diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts
index bebef1d..39fc5ee 100644
--- a/src/core/metrics/reducer.ts
+++ b/src/core/metrics/reducer.ts
@@ -68,6 +68,51 @@ function liveTurnToMetrics(lt: LiveTurn): TurnMetrics {
return base;
}
+/**
+ * A step's contribution to the live context size: `inputTokens + outputTokens`,
+ * or `undefined` when the step has no usage yet OR its counters are not safe to
+ * sum (non-finite / negative — defensive: a corrupt provider report must never
+ * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT
+ * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of
+ * `inputTokens`, so adding them would double-count.
+ */
+function stepContextSize(usage: Usage | undefined): number | undefined {
+ if (usage === undefined) return undefined;
+ const { inputTokens, outputTokens } = usage;
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined;
+ if (inputTokens < 0 || outputTokens < 0) return undefined;
+ return inputTokens + outputTokens;
+}
+
+/**
+ * The context size an IN-FLIGHT (not-done) turn occupies right now — for
+ * progressive display DURING a turn (before it seals), so the indicator updates
+ * after each step instead of waiting for `done`.
+ *
+ * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the
+ * caller, `selectCurrentContextSize`, reaches it solely for entries with
+ * `total === null`, i.e. `lt.done === false`). Finalized turns use their
+ * authoritative `contextSize` instead; `doneContextSize` is read on the
+ * `total` path, never here.
+ *
+ * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens`
+ * (scanning newest → oldest by first-seen step order): each step's input
+ * already includes all prior context (the prompt is re-prefilled every step), so
+ * the last step's input+output is the true occupancy — the same definition
+ * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage
+ * wins immediately, even mid-stream. Steps with no usage or unsafe usage are
+ * skipped, falling back to the next older usable step. `undefined` when no step
+ * has reported usable usage yet.
+ */
+function liveTurnContextSize(lt: LiveTurn): number | undefined {
+ for (let i = lt.stepOrder.length - 1; i >= 0; i--) {
+ const step = lt.stepMap.get(lt.stepOrder[i] ?? "");
+ const ctx = stepContextSize(step?.usage);
+ if (ctx !== undefined) return ctx;
+ }
+ return undefined;
+}
+
function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] {
const existing = state.live.get(turnId);
if (existing !== undefined) return [state, existing];
@@ -180,6 +225,12 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric
/**
* Store durable (sealed) metrics from the backend. These win over live data
* for any shared `turnId`.
+ *
+ * Once durable (authoritative) data covers a turn, its live (in-memory) copy
+ * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map
+ * doesn't grow unbounded over a long conversation. There is no display gap:
+ * the durable entry replaces the live one atomically in the same fold, and
+ * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it.
*/
export function applyDurableMetrics(
state: MetricsState,
@@ -187,14 +238,27 @@ export function applyDurableMetrics(
): MetricsState {
const newDurable = new Map(state.durable);
const newDurableOrder = [...state.durableOrder];
+ const prunedIds = new Set<string>();
for (const turn of turns) {
if (!newDurable.has(turn.turnId)) {
newDurableOrder.push(turn.turnId);
}
newDurable.set(turn.turnId, turn);
+ if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId);
}
+
+ if (prunedIds.size === 0) {
+ return { ...state, durable: newDurable, durableOrder: newDurableOrder };
+ }
+
+ const newLive = new Map(state.live);
+ for (const id of prunedIds) newLive.delete(id);
+ const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id));
+
return {
...state,
+ live: newLive,
+ liveOrder: newLiveOrder,
durable: newDurable,
durableOrder: newDurableOrder,
};
@@ -247,17 +311,35 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr
* Select the conversation's CURRENT context size — the tokens it occupies right
* now. Per the wire contract a client reads the LATEST turn's `contextSize`; we
* scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED
- * `contextSize` (a finalized turn whose provider reported per-step usage).
+ * value.
+ *
+ * For a FINALIZED turn (`done` event or durable data) we use its authoritative
+ * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY
+ * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the
+ * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so
+ * the indicator updates after each step completes instead of waiting for the
+ * turn to seal. An in-flight turn with no step usage yet is skipped, falling
+ * back to the next older finalized turn.
*
- * Returns `undefined` ("unknown") when no finalized turn carries a context size —
- * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over
+ * Returns `undefined` ("unknown") when no turn carries a context size — the
+ * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over
* live for a shared `turnId` (it is the persisted, authoritative value).
*/
export function selectCurrentContextSize(state: MetricsState): number | undefined {
const ordered = selectOrderedTurnMetrics(state);
for (let i = ordered.length - 1; i >= 0; i--) {
- const total = ordered[i]?.total;
- if (total?.contextSize !== undefined) return total.contextSize;
+ const entry = ordered[i];
+ if (entry === undefined) continue;
+ if (entry.total !== null) {
+ if (entry.total.contextSize !== undefined) return entry.total.contextSize;
+ continue;
+ }
+ // In-flight turn: progressive context size from the latest step with usage.
+ const lt = state.live.get(entry.turnId);
+ if (lt !== undefined) {
+ const live = liveTurnContextSize(lt);
+ if (live !== undefined) return live;
+ }
}
return undefined;
}
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 5278737..9911438 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -74,9 +74,12 @@ export interface ChatStore {
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
/**
- * The conversation's current context size (tokens occupied) — the latest
- * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
- * known yet. Never `0` for the unknown case.
+ * The conversation's current context size (tokens occupied) — updated
+ * PROGRESSIVELY: during an in-flight turn, the most recent step's
+ * `inputTokens + outputTokens` (each step's input already includes all prior
+ * context); once the turn seals, its authoritative `contextSize`. `undefined`
+ * ("unknown") when no step has reported usage yet. Never `0` for the unknown
+ * case.
*/
readonly currentContextSize: number | undefined;
/**
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index afe1e3c..04c28cd 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -39,8 +39,10 @@
onQueue?: (text: string) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
+ // Current context occupancy — updated progressively during a turn (the
+ // latest step's input+output) and finalized to the turn's `contextSize` on
+ // seal, 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;
@@ -66,7 +68,6 @@
const canSend = $derived(hasText || hasImages);
const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
@@ -395,7 +396,7 @@
{/if}
<span class="shrink-0 whitespace-nowrap font-mono">
- {#if hasUsage}
+ {#if usage.current !== null}
{formatCompactTokens(usage.current)}{#if usage.max !== null}<span
class="text-base-content/40"
>