summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:39:20 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:39:20 +0900
commit2756730aeba633c3cc331500bcfb9a67d85dc892 (patch)
tree5fd7615e6731e5998ebe3e5dbe8fff4c360aa081
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-2756730aeba633c3cc331500bcfb9a67d85dc892.tar.gz
dispatch-2756730aeba633c3cc331500bcfb9a67d85dc892.zip
Add status bar beneath chat input with send/stop button and context display
Restructure ChatInput into two stacked bars: - Top bar: auto-resizing textarea + fixed-width send/stop button that morphs in place (no layout shift) across idle/generating states. - Bottom bar: agent status icon, context-window fill bar, and compact token count + percent (inert bar when model max is unknown). Wire contextLimit prop from App.svelte into ChatInput, reusing the shared computeContextUsage helper so it agrees with the sidebar.
-rw-r--r--packages/frontend/src/App.svelte2
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte145
2 files changed, 111 insertions, 36 deletions
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/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 0c99078..95712c7 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;
@@ -8,6 +11,36 @@ let inputValue = $state("");
const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
const tabId = $derived(tabStore.activeTab?.id ?? "");
+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(() => {
inputEl?.focus();
@@ -49,45 +82,87 @@ function submit() {
inputValue = "";
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}
+ bind: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={resize}
+ ></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}
- bind: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={resize}
- ></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>