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 /src/features/chat/ui | |
| 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.
Diffstat (limited to 'src/features/chat/ui')
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 152 | ||||
| -rw-r--r-- | src/features/chat/ui/ContextSizeBadge.svelte | 20 |
2 files changed, 139 insertions, 33 deletions
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> |
