diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 01:10:56 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 01:10:56 +0900 |
| commit | 0e71903cc1419d20fbd593fd7330defdc4628af2 (patch) | |
| tree | c669251424f49f6d8e5b3ec17e03f486d46740a1 | |
| parent | 6bd7b39f6f53dd8f3743347a1cb72c2f74424dd8 (diff) | |
| download | dispatch-web-0e71903cc1419d20fbd593fd7330defdc4628af2.tar.gz dispatch-web-0e71903cc1419d20fbd593fd7330defdc4628af2.zip | |
feat(chat): old-Dispatch composer layout — textarea + send + status bar
Restore the ergonomic composer from old Dispatch: an auto-resizing textarea
(1→7 lines) with a fixed-width Send button beside it, and a status bar BELOW
holding a status icon · context-window fill bar (escalating success/warning/
error color) · compact token count (current / limit · pct%).
The bar reuses the latest turn's contextSize as current usage and HARDCODES a
1,000,000-token window limit as a placeholder (real per-model limit is the next
backend ask). Absorbs the standalone ContextSizeBadge (removed). Pure helpers
computeContextUsage + formatCompactTokens added to core/metrics (tested).
540 tests green.
| -rw-r--r-- | GLOSSARY.md | 2 | ||||
| -rw-r--r-- | backend-handoff.md | 11 | ||||
| -rw-r--r-- | src/app/App.svelte | 8 | ||||
| -rw-r--r-- | src/core/metrics/format.test.ts | 44 | ||||
| -rw-r--r-- | src/core/metrics/format.ts | 39 | ||||
| -rw-r--r-- | src/core/metrics/index.ts | 3 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 1 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 152 | ||||
| -rw-r--r-- | src/features/chat/ui/ContextSizeBadge.svelte | 20 |
9 files changed, 237 insertions, 43 deletions
diff --git a/GLOSSARY.md b/GLOSSARY.md index d632c8d..2a6904a 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -20,7 +20,7 @@ | **TTFT** (time to first token) | Per-step latency: generation stream start → first content token (text or reasoning). One per step (each step re-prefills). On the wire as `step-complete.ttftMs` / `StepMetrics.ttftMs` (optional). | time-to-first-byte | | **decode time** | Per-step generation time after the first token (first token → stream end = `genTotalMs − ttftMs`). On the wire as `step-complete.decodeMs` / `StepMetrics.decodeMs` (optional). | — | | **context size** | The tokens a conversation currently occupies: the most recent turn's FINAL step `inputTokens + outputTokens` (NOT the aggregate per-turn `usage`, which sums per-step prompts and overcounts a multi-step turn). On the wire as `TurnDoneEvent.contextSize` (live `done`) + `TurnMetrics.contextSize` (persisted); the FE reads the LATEST turn's value as current usage, and treats `undefined` as "unknown" (renders a placeholder, never `0`). Mirrors the backend GLOSSARY. | context usage, context length, tokens used (and do NOT call it "context window" — that's the limit) | -| **context window** | The model's MAXIMUM token capacity (the limit a **context size** is measured against). A FUTURE backend field — not on the wire yet; the FE shows context size alone (no `size / limit` denominator) until it ships. | max context, token limit (distinct from **context size**, the current usage) | +| **context window** | The model's MAXIMUM token capacity (the limit a **context size** is measured against). A FUTURE backend field — not on the wire yet. **Placeholder:** the composer status bar currently HARDCODES a `1,000,000`-token window for the `size / limit · pct%` readout + fill bar; swap to the real per-model value when the backend ships it (see `backend-handoff.md` §3). | max context, token limit (distinct from **context size**, the current usage) | ## Frontend-specific | Term | Meaning | Aliases to avoid | diff --git a/backend-handoff.md b/backend-handoff.md index e9b128a..847282b 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -111,11 +111,12 @@ harden `/chat` to treat blank as "not provided" if we ever want it — not neede ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** (the denominator for context size) — the context-size handoff - flagged this as the separate, later field. The FE now shows current size alone (e.g. "34,102 tokens - in context"); once a per-model/per-turn `contextWindow` (max token capacity) ships, the FE can render - `contextSize / limit` (e.g. "34,102 / 200,000") + a usage bar. GLOSSARY term reserved: "context window" - = the limit (distinct from "context size" = current usage). **Likely the next ask** — raise when the - backend can source the model's advertised window. + flagged this as the separate, later field. **The FE already renders `contextSize / limit · pct%` + a + fill bar in the composer status bar, but the limit is currently HARDCODED to `1,000,000` as a + placeholder** (`MAX_CONTEXT` in `features/chat/ui/Composer.svelte`; GLOSSARY "context window" notes it). + When a per-model/per-turn `contextWindow` (max token capacity) ships, wire the real value through (drop + the hardcode) so the bar/percent are accurate. **Likely the next ask** — raise when the backend can + source the model's advertised window. - `GET /conversations` — conversation list / sidebar (history explorer / switcher); could also expose a per-conversation "last model" so a reopened tab seeds its model from the server instead of localStorage. - `POST /conversations/:id/cancel` — "stop generating". diff --git a/src/app/App.svelte b/src/app/App.svelte index 32db54f..dbb346a 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -10,7 +10,6 @@ ChatView, Composer, manifest as chatManifest, - ContextSizeBadge, ModelSelector, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; @@ -217,8 +216,11 @@ <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> </div> - <ContextSizeBadge contextSize={store.activeChat.currentContextSize} /> - <Composer onSend={handleSend} /> + <Composer + onSend={handleSend} + contextSize={store.activeChat.currentContextSize} + status={store.activeChat.error ? "error" : "idle"} + /> </div> <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 7c143d7..6a4bd38 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -2,8 +2,10 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { computeCachePct, + computeContextUsage, computeExpectedCachePct, computeTps, + formatCompactTokens, formatContextSize, viewCacheRate, viewExpectedCache, @@ -323,3 +325,45 @@ describe("formatContextSize", () => { expect(formatContextSize(0)).toBe("0 tokens in context"); }); }); + +describe("formatCompactTokens", () => { + it("renders sub-1k counts as-is", () => { + expect(formatCompactTokens(0)).toBe("0"); + expect(formatCompactTokens(812)).toBe("812"); + }); + + it("renders thousands with one decimal (rounded ≥100k)", () => { + expect(formatCompactTokens(12300)).toBe("12.3k"); + expect(formatCompactTokens(150000)).toBe("150k"); + }); + + it("renders millions with one decimal", () => { + expect(formatCompactTokens(1_200_000)).toBe("1.2M"); + expect(formatCompactTokens(1_000_000)).toBe("1.0M"); + }); +}); + +describe("computeContextUsage", () => { + it("computes an unrounded clamped percent against the limit", () => { + const u = computeContextUsage(34102, 1_000_000); + expect(u.current).toBe(34102); + expect(u.max).toBe(1_000_000); + expect(u.percent).toBeCloseTo(3.4102, 4); + }); + + it("treats unknown contextSize as current 0", () => { + const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBe(0); + expect(u.percent).toBe(0); + }); + + it("clamps percent to [0,100] and over-limit reads 100", () => { + expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); + }); + + it("max null (no/zero limit) ⇒ percent null", () => { + expect(computeContextUsage(5000, null).percent).toBeNull(); + expect(computeContextUsage(5000, 0).percent).toBeNull(); + expect(computeContextUsage(5000, null).max).toBeNull(); + }); +}); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index d8dd2cc..4d69f25 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -28,6 +28,45 @@ export function formatContextSize(n: number | undefined): string { return `${formatTokens(n)} tokens in context`; } +/** + * Compact token count for a slim status bar: `812`, `12.3k`, `1.2M`. Full + * thousands-separated numbers live elsewhere; this trades precision for width. + */ +export function formatCompactTokens(n: number): string { + if (n < 1000) return `${n}`; + if (n < 1_000_000) { + const k = n / 1000; + return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; + } + const m = n / 1_000_000; + return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; +} + +/** + * 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). + */ +export interface ContextUsage { + readonly current: number; + readonly max: number | null; + readonly percent: number | null; +} + +export function computeContextUsage( + contextSize: number | undefined, + contextLimit: number | null | undefined, +): ContextUsage { + const current = contextSize ?? 0; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + return { current, max, percent }; +} + /** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */ export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null { if (elapsedMs === undefined || elapsedMs <= 0) return null; diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 773d697..36cd96f 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,7 +1,10 @@ export { + type ContextUsage, computeCachePct, + computeContextUsage, computeExpectedCachePct, computeTps, + formatCompactTokens, formatContextSize, viewCacheRate, viewExpectedCache, diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index adfb670..18ed693 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,7 +6,6 @@ export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export { default as Composer } from "./ui/Composer.svelte"; -export { default as ContextSizeBadge } from "./ui/ContextSizeBadge.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 3762340..24c2c19 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,7 +1,59 @@ <script lang="ts"> - let { onSend }: { onSend: (text: string) => void } = $props(); + 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 MAX_LINES = 7; + + let { + onSend, + contextSize = undefined, + status = "idle", + }: { + onSend: (text: string) => void; + // Current context occupancy (latest turn's contextSize), or `undefined` + // when unknown — the status bar then shows "— tokens", never 0%. + contextSize?: number | undefined; + // Coarse agent status for the status-bar icon. + status?: "idle" | "running" | "error"; + } = $props(); let text = $state(""); + let inputEl: HTMLTextAreaElement | undefined; + + const hasText = $derived(text.trim().length > 0); + const usage = $derived(computeContextUsage(contextSize, MAX_CONTEXT)); + const hasUsage = $derived(contextSize !== undefined); + + // As the window fills, escalate color: calm → warning → danger. + function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; + } + + function resize(): void { + const el = inputEl; + if (!el) return; + el.style.height = "auto"; + const style = getComputedStyle(el); + const lineHeight = Number.parseFloat(style.lineHeight) || 20; + const paddingY = + Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom); + const borderY = + Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth); + const maxHeight = lineHeight * MAX_LINES + paddingY + borderY; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + } + + // Re-run resize whenever the value changes (covers programmatic clears too). + $effect(() => { + void text; + resize(); + }); function handleSubmit(): void { const trimmed = text.trim(); @@ -18,16 +70,90 @@ } </script> -<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> - <textarea - class="textarea textarea-bordered flex-1" - bind:value={text} - onkeydown={handleKeydown} - placeholder="Type a message..." - rows="3" - aria-label="Message input" - ></textarea> - <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}> - Send - </button> +<form + class="flex flex-col" + onsubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} +> + <!-- Top bar: expanding textarea + send button --> + <div class="flex items-end gap-2 px-4 pt-3 pb-2"> + <textarea + bind:this={inputEl} + class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto" + bind:value={text} + onkeydown={handleKeydown} + placeholder="Type a message..." + rows="1" + aria-label="Message input" + ></textarea> + <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}> + Send + </button> + </div> + + <!-- Bottom status bar: status icon · context-window fill · token count --> + <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> + <span class="shrink-0"> + {#if status === "running"} + <span class="loading loading-spinner loading-xs text-primary"></span> + {:else if status === "error"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-error" + aria-label="Error" + > + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" y1="8" x2="12" y2="12"></line> + <line x1="12" y1="16" x2="12.01" y2="16"></line> + </svg> + {:else} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-success" + aria-label="Idle" + > + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {/if} + </span> + + {#if usage.percent !== null} + <progress + class="progress h-2 flex-1 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress> + {/if} + + <span class="shrink-0 whitespace-nowrap font-mono"> + {#if hasUsage} + {formatCompactTokens(usage.current)}{#if usage.max !== null}<span + class="text-base-content/40" + > + / {formatCompactTokens(usage.max)}</span + >{/if} + {#if usage.percent !== null} + <span class="ml-1">· {usage.percent.toFixed(1)}%</span> + {/if} + {:else} + <span class="text-base-content/40">— tokens</span> + {/if} + </span> + </div> </form> diff --git a/src/features/chat/ui/ContextSizeBadge.svelte b/src/features/chat/ui/ContextSizeBadge.svelte deleted file mode 100644 index 475d54f..0000000 --- a/src/features/chat/ui/ContextSizeBadge.svelte +++ /dev/null @@ -1,20 +0,0 @@ -<script lang="ts"> - import { formatContextSize } from "../../../core/metrics"; - - let { - contextSize, - }: { - // The conversation's current context size (tokens occupied), or `undefined` - // ("unknown") when no finalized turn has reported one yet. Never `0` for the - // unknown case — `formatContextSize` renders a placeholder instead. - contextSize: number | undefined; - } = $props(); - - const label = $derived(formatContextSize(contextSize)); -</script> - -<div class="px-4 pb-1 text-xs opacity-60" aria-live="polite"> - <span title="The model's max context window is not reported yet — current usage only."> - {label} - </span> -</div> |
