summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:50:30 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:50:30 +0900
commitd022877e3a4c954adafce3894f70c176dee76433 (patch)
tree4dc5e033c88ee123c82adc964e50cc788f16d7ed /packages/frontend/src/lib/components
parent58b985b9c82c7c1acb98ac8799b8409d5c403642 (diff)
parent2e71dbfbfb883c8fa31f40969fdb249043a37ec0 (diff)
downloaddispatch-d022877e3a4c954adafce3894f70c176dee76433.tar.gz
dispatch-d022877e3a4c954adafce3894f70c176dee76433.zip
Merge branch 'dev' into cr/claude-reset-fix
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte7
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte162
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte21
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte89
4 files changed, 227 insertions, 52 deletions
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 0c99078..079ef4a 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -1,15 +1,53 @@
<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;
-let inputValue = $state("");
const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
const tabId = $derived(tabStore.activeTab?.id ?? "");
+// The current input text lives on the active tab (in-memory draft), so
+// 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.
+ void tabId;
inputEl?.focus();
});
@@ -29,13 +67,19 @@ function resize() {
el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
}
-// Re-run resize whenever the value changes (covers programmatic clears too).
+// Re-run resize whenever the value changes (covers tab switches and
+// programmatic clears too).
$effect(() => {
// Touch inputValue so this effect tracks it.
void inputValue;
resize();
});
+function handleInput(e: Event) {
+ if (!tabId) return;
+ tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value);
+}
+
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -46,48 +90,90 @@ function handleKeydown(e: KeyboardEvent) {
function submit() {
const text = inputValue.trim();
if (!text) return;
- inputValue = "";
+ 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}
- 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>
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}
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
index 3cbd849..4fbe3b1 100644
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ b/packages/frontend/src/lib/components/TabBar.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { tick } from "svelte";
import { tabStore } from "../tabs.svelte.js";
function statusColor(status: string): string {
@@ -20,6 +21,59 @@ const activeUserTabId = $derived(
? activeTab.parentTabId
: tabStore.activeTabId,
);
+
+// ── Drag-and-drop reorder (user tabs only) ──
+// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte.
+let dragIndex = $state<number | null>(null);
+let dragOverIndex = $state<number | null>(null);
+
+function dropReorder(targetIndex: number): void {
+ if (dragIndex !== null && dragIndex !== targetIndex) {
+ const ids = userTabs.map((t) => t.id);
+ const moved = ids.splice(dragIndex, 1)[0];
+ if (moved) {
+ ids.splice(targetIndex, 0, moved);
+ tabStore.reorderTabs(ids);
+ }
+ }
+ dragIndex = null;
+ dragOverIndex = null;
+}
+
+// ── Double-click rename (user tabs only) ──
+let editingTabId = $state<string | null>(null);
+let editValue = $state("");
+let editInputEl = $state<HTMLInputElement | undefined>(undefined);
+
+async function startRename(tab: { id: string; title: string }): Promise<void> {
+ editingTabId = tab.id;
+ editValue = tab.title;
+ await tick();
+ editInputEl?.focus();
+ editInputEl?.select();
+}
+
+function commitRename(): void {
+ if (editingTabId === null) return;
+ const id = editingTabId;
+ editingTabId = null;
+ const next = editValue.trim();
+ if (next) tabStore.renameTab(id, next);
+}
+
+function cancelRename(): void {
+ editingTabId = null;
+}
+
+function handleRenameKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+}
</script>
<!-- Top row: user tabs -->
@@ -45,19 +99,48 @@ const activeUserTabId = $derived(
+
</button>
- {#each userTabs as tab (tab.id)}
+ {#each userTabs as tab, i (tab.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="tab"
- class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''}"
+ class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}"
+ draggable={editingTabId === tab.id ? "false" : "true"}
onclick={() => tabStore.switchTab(tab.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }}
+ ondragstart={(e) => {
+ dragIndex = i;
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
+ }}
+ ondragover={(e) => {
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
+ dragOverIndex = i;
+ }}
+ ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }}
+ ondrop={(e) => { e.preventDefault(); dropReorder(i); }}
+ ondragend={() => { dragIndex = null; dragOverIndex = null; }}
tabindex="0"
>
<span class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
<span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- <span class="max-w-32 truncate text-xs">{tab.title}</span>
+ {#if editingTabId === tab.id}
+ <input
+ bind:this={editInputEl}
+ bind:value={editValue}
+ class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40"
+ onclick={(e) => e.stopPropagation()}
+ ondblclick={(e) => e.stopPropagation()}
+ onkeydown={handleRenameKeydown}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="max-w-32 truncate text-xs"
+ ondblclick={(e) => { e.stopPropagation(); startRename(tab); }}
+ title="Double-click to rename"
+ >{tab.title}</span>
+ {/if}
</span>
<button
type="button"