diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 14:47:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 14:47:45 +0900 |
| commit | 2e71dbfbfb883c8fa31f40969fdb249043a37ec0 (patch) | |
| tree | 903bdf7bf0cbdc901f654fb3aa20977979d39728 | |
| parent | 3ebcd49c404ed287a97af159ac8adfa63d572849 (diff) | |
| parent | a9097498b0e90f45a0eaf1ce2d43275dc9ac8fa4 (diff) | |
| download | dispatch-2e71dbfbfb883c8fa31f40969fdb249043a37ec0.tar.gz dispatch-2e71dbfbfb883c8fa31f40969fdb249043a37ec0.zip | |
Merge branch 'dev' into tc/tab-controls
# Conflicts:
# packages/frontend/src/lib/components/ChatInput.svelte
| -rw-r--r-- | HANDOFF.md | 63 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/CacheRatePanel.svelte | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 145 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 21 |
5 files changed, 191 insertions, 47 deletions
diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..5755279 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,63 @@ +# Handoff — sb/status-bar + +Add a status bar beneath the chat input that houses the send button and shows +generation status + context-window usage. + +## Files changed +- `packages/frontend/src/lib/components/ChatInput.svelte` — restructured into + **two stacked bars** (wrapped in `flex flex-col`): + - **Top bar:** the existing auto-resizing textarea + a single, fixed-width + (`w-20`) send/stop button that morphs in place so the layout never shifts. + Three states: + - not generating → `btn-primary` **Send**, disabled when the box is empty + (unchanged look). + - generating + empty box → **Stop** (`btn-error btn-outline`, spinner + + "Stop"), calls `tabStore.stopGeneration(tabId)`. + - generating + text in box → enabled **Send** (queues the message via + `tabStore.sendMessage`). + - **Bottom bar:** agent status icon on the left (✓ idle / spinner running / + ✗ error), a context-window fill `progress` bar filling the middle, and a + compact token count + percent on the right (e.g. `12.3k / 200k · 6.1%`). + When the model's max context is unknown the bar renders inert/disabled + (`opacity-40`, no value) and the right side shows the bare current token + count with no percent. Before the first response it reads `— tokens`. +- `packages/frontend/src/App.svelte` — pass the already-computed `contextLimit` + into `<ChatInput {contextLimit} />` (same value handed to the sidebar). + +## Public surface changed +- `ChatInput.svelte` gained one optional prop: `contextLimit?: number | null` + (defaults to `null`). No other exported API/type changes. +- Reuses the shared `computeContextUsage()` helper (`lib/context-window.ts`) and + the same fill-color thresholds (calm→warning→danger) as the Context Window + sidebar panel, so the two displays always agree. Compact `k`/`M` token + formatting is local to `ChatInput` (the sidebar keeps full `toLocaleString`). + +## Design decisions (agreed with requester) +- Two stacked bars; send button kept (not dropped) for discoverability. +- Send and Stop are the **same button** at a fixed width — no layout jump. +- Bottom bar: status icon left; context number + percent right; progress bar + fills the remaining width; disabled/inert bar when the model has no known max. +- Token format: compact (`12.3k`) for the slim bar. + +## Verification status — PASS +- `bun run check` (biome): **PASS** — "Checked 163 files… No fixes applied." +- `bun run test` (vitest): **PASS** — 35 files, 552 tests. +- `bun run --cwd packages/frontend typecheck` (svelte-check): **PASS** — 0 + errors, 0 warnings. +- Re-verified all three after `git merge --no-edit dev` — still all-green. +- Note: `bun install` was required first; deps were not present in the worktree. + +## Published +- Yes. Feature commit `2756730`, merged `dev` down (`f0207a7`, clean merge — + picked up another agent's CacheRatePanel/KeyUsage changes + their HANDOFF.md), + pushed fast-forward `3f0bfe7..f0207a7 → dev`. +- This HANDOFF.md was rewritten from the incoming `m1/minor-fixes` handoff (that + content remains preserved in git history on its own merge). + +## Assumptions / known gaps +- User visually confirmed the UI before merge ("sweet, merge it in"). +- No component-render tests were added for the Svelte markup, consistent with + the existing repo (these panels have no render tests). Logic is exercised + indirectly via the unchanged `computeContextUsage` unit tests. +- Context usage reflects the most recent turn (`last.inputTokens + + last.outputTokens`) — identical semantics to the sidebar panel. diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index ecfdc9f..a0b25b7 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -174,7 +174,7 @@ onMount(() => { <div class="flex-1 overflow-hidden"> <ChatPanel /> </div> - <ChatInput /> + <ChatInput {contextLimit} /> </div> <!-- Right sidebar: overlay on small screens, inline on large --> diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte index c35cbb5..88985a0 100644 --- a/packages/frontend/src/lib/components/CacheRatePanel.svelte +++ b/packages/frontend/src/lib/components/CacheRatePanel.svelte @@ -55,7 +55,7 @@ const lastHitPct = $derived( {#if tabTitle} <span class="badge badge-xs badge-ghost">{tabTitle}</span> {/if} - <span class="badge badge-xs ml-auto">{cacheStats.requests} req</span> + <span class="badge badge-xs ml-auto whitespace-nowrap">{cacheStats.requests} req</span> </div> <!-- Headline cumulative hit rate --> @@ -120,10 +120,5 @@ const lastHitPct = $derived( </div> </div> </div> - - <p class="text-xs text-base-content/40"> - Cache reads cost ~10% of fresh input; writes cost ~25% more. A high hit - rate after the first turn means caching is working. Resets on reload. - </p> {/if} </div> diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 71eb496..079ef4a 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -1,6 +1,9 @@ <script lang="ts"> +import { computeContextUsage } from "../context-window.js"; import { tabStore } from "../tabs.svelte.js"; +const { contextLimit = null }: { contextLimit?: number | null } = $props(); + const MAX_LINES = 7; let inputEl: HTMLTextAreaElement | undefined; @@ -11,6 +14,36 @@ const tabId = $derived(tabStore.activeTab?.id ?? ""); // switching tabs saves the current draft and restores the target tab's text // automatically — drafts are never lost or clobbered by tab switching. const inputValue = $derived(tabStore.activeTab?.draft ?? ""); +const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null); + +const isRunning = $derived(agentStatus === "running"); +const hasText = $derived(inputValue.trim().length > 0); +// While generating with an empty box, the primary action is "stop". With text +// in the box, it stays "send" (the message is queued behind the live turn). +const showStop = $derived(isRunning && !hasText); + +const usage = $derived(computeContextUsage(cacheStats, contextLimit)); +const hasUsage = $derived((cacheStats?.last ?? null) !== null); + +// As the window fills, escalate color: calm → warning → danger. Mirrors the +// Context Window sidebar view so the two displays agree. +function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; +} + +// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live +// in the sidebar's Context Window panel. +function fmtCompact(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`; +} $effect(() => { // Re-focus when switching tabs. @@ -60,45 +93,87 @@ function submit() { if (tabId) tabStore.setDraft(tabId, ""); tabStore.sendMessage(text); } + +function primaryAction() { + if (showStop) { + tabStore.stopGeneration(tabId); + return; + } + submit(); +} </script> -<div class="flex items-end gap-2 p-3"> - {#if agentStatus === "running"} +<div class="flex flex-col"> + <!-- Top bar: expanding textarea + send/stop action --> + <div class="flex items-end gap-2 px-3 pt-3 pb-2"> + <textarea + bind:this={inputEl} + value={inputValue} + rows="1" + placeholder="Type a message..." + class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" + onkeydown={handleKeydown} + oninput={handleInput} + ></textarea> + <!-- Single fixed-width button across all states so the layout never + shifts when it morphs between Send and Stop. --> <button type="button" - class="btn btn-ghost gap-1 btn-sm lg:btn-xs" - onclick={() => tabStore.stopGeneration(tabId)} - title="Stop generation" + class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}" + disabled={!showStop && !hasText} + onclick={primaryAction} + title={showStop ? "Stop generation" : "Send message"} > - <span class="loading loading-spinner loading-sm text-primary" style="pointer-events: auto"></span> - <span class="text-xs">Stop</span> + {#if showStop} + <span class="loading loading-spinner loading-sm"></span> + Stop + {:else} + Send + {/if} </button> - {:else if agentStatus === "idle"} - <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="w-5 h-5 text-success shrink-0 mb-2"> - <polyline points="20 6 9 17 4 12"></polyline> - </svg> - {:else if agentStatus === "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="w-5 h-5 text-error shrink-0 mb-2"> - <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> - {/if} - <textarea - bind:this={inputEl} - value={inputValue} - rows="1" - placeholder="Type a message..." - class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" - onkeydown={handleKeydown} - oninput={handleInput} - ></textarea> - <button - type="button" - class="btn btn-primary" - disabled={!inputValue.trim()} - onclick={submit} - > - Send - </button> + </div> + + <!-- Bottom bar: status icon · context progress · token count --> + <div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50"> + <!-- Status icon --> + <span class="shrink-0"> + {#if agentStatus === "running"} + <span class="loading loading-spinner loading-xs text-primary"></span> + {:else if agentStatus === "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="w-4 h-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="w-4 h-4 text-success" aria-label="Idle"> + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {/if} + </span> + + <!-- Context-window fill bar --> + {#if usage.percent !== null} + <progress + class="progress flex-1 h-2 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <!-- Model's max context is unknown → inert, disabled bar. --> + <progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress> + {/if} + + <!-- Context size + percent --> + <span class="shrink-0 font-mono whitespace-nowrap"> + {#if hasUsage} + {fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(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> </div> diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index 00d179e..7c0cadc 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -131,6 +131,17 @@ function progressClass(utilization: number): string { return "progress-success"; } +// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the +// reset window). Red once usage hits 90%, otherwise green when usage is at or +// behind the dot and orange when it has run ahead of it. Falls back to the +// plain threshold coloring when no dot is present (elapsedPct < 0). +function pacedProgressClass(percentUsed: number, elapsedPct: number): string { + if (percentUsed >= 90) return "progress-error"; + if (elapsedPct < 0) return progressClass(percentUsed / 100); + if (percentUsed <= elapsedPct) return "progress-success"; + return "progress-warning"; +} + function formatDate(ts: number): string { const diff = ts - Date.now(); const days = Math.floor(diff / 86400000); @@ -245,7 +256,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -266,7 +277,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -330,7 +341,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -351,7 +362,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -372,7 +383,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} |
