summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/Composer.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit0a271e3e8aecb1ec9db3eea873b3df1ecaab8b99 (patch)
tree0a5a6e62acf2d8e15ec69fd09f641cd75a1bcb10 /src/features/chat/ui/Composer.svelte
parent397ce9b65b888121d8c7e909c37dfdbffe914673 (diff)
parent576f539670f406280f8398abf78a867d70b683b8 (diff)
downloaddispatch-web-0a271e3e8aecb1ec9db3eea873b3df1ecaab8b99.tar.gz
dispatch-web-0a271e3e8aecb1ec9db3eea873b3df1ecaab8b99.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/chat/ui/Composer.svelte')
-rw-r--r--src/features/chat/ui/Composer.svelte358
1 files changed, 179 insertions, 179 deletions
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index fe9ea94..96b4b3a 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,198 +1,198 @@
<script lang="ts">
- import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
- const FALLBACK_CONTEXT_WINDOW = 1_000_000;
- const MAX_LINES = 7;
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
- let {
- onSend,
- onQueue,
- onStop,
- contextSize = undefined,
- contextWindow = undefined,
- status = "idle",
- }: {
- onSend: (text: string) => void;
- /**
- * Enqueue a steering message (`chat.queue`). When provided AND the status
- * is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. When absent, `onSend` is
- * used regardless (tests / non-steering contexts).
- */
- onQueue?: (text: string) => void;
- /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
- onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
- contextSize?: number | undefined;
- /** Per-model context window (max tokens) from `GET /models` modelInfo. */
- contextWindow?: number | undefined;
- // Coarse agent status for the status-bar icon.
- status?: "idle" | "running" | "error";
- } = $props();
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ onSend: (text: string) => void;
+ /**
+ * Enqueue a steering message (`chat.queue`). When provided AND the status
+ * is `running`, the send button becomes a "Queue" button that steers the
+ * in-flight turn instead of starting a new one. When absent, `onSend` is
+ * used regardless (tests / non-steering contexts).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy (latest turn's contextSize), or `undefined`
+ // when unknown — the status bar then shows "— tokens", never 0%.
+ contextSize?: number | undefined;
+ /** Per-model context window (max tokens) from `GET /models` modelInfo. */
+ contextWindow?: number | undefined;
+ // Coarse agent status for the status-bar icon.
+ status?: "idle" | "running" | "error";
+ } = $props();
- let text = $state("");
- let inputEl: HTMLTextAreaElement | undefined;
+ let text = $state("");
+ let inputEl: HTMLTextAreaElement | undefined;
- const hasText = $derived(text.trim().length > 0);
- const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
- const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
+ const hasText = $derived(text.trim().length > 0);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+ const hasUsage = $derived(contextSize !== undefined);
- // One button, three modes:
- // - idle → "Send" (starts a turn via chat.send)
- // - running + text → "Queue" (steers via chat.queue)
- // - running + empty → "Stop" (aborts via POST /stop)
- const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
- if (status === "running" && !hasText && onStop !== undefined) return "stop";
- if (status === "running" && hasText && onQueue !== undefined) return "queue";
- return "send";
- });
- const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message...");
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running + text → "Queue" (steers via chat.queue)
+ // - running + empty → "Stop" (aborts via POST /stop)
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (status === "running" && !hasText && onStop !== undefined) return "stop";
+ if (status === "running" && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "running" ? "Steer the conversation..." : "Type a message...",
+ );
- // 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";
- }
+ // 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";
- }
+ 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();
- });
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- if (buttonMode === "queue") {
- onQueue?.(trimmed);
- } else {
- onSend(trimmed);
- }
- text = "";
- }
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ if (buttonMode === "queue") {
+ onQueue?.(trimmed);
+ } else {
+ onSend(trimmed);
+ }
+ text = "";
+ }
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
<form
- class="flex flex-col"
- onsubmit={(e) => {
- e.preventDefault();
- handleSubmit();
- }}
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
>
- <!-- Top bar: expanding textarea + single context-aware 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={placeholder}
- rows="1"
- aria-label="Message input"
- ></textarea>
- {#if buttonMode === "stop"}
- <button
- class="btn btn-error w-20 shrink-0"
- type="button"
- aria-label="Stop generation"
- onclick={() => onStop?.()}
- >
- Stop
- </button>
- {:else}
- <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
- {buttonMode === "queue" ? "Queue" : "Send"}
- </button>
- {/if}
- </div>
+ <!-- Top bar: expanding textarea + single context-aware 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}
+ rows="1"
+ aria-label="Message input"></textarea>
+ {#if buttonMode === "stop"}
+ <button
+ class="btn btn-error w-20 shrink-0"
+ type="button"
+ aria-label="Stop generation"
+ onclick={() => onStop?.()}
+ >
+ Stop
+ </button>
+ {:else}
+ <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </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>
+ <!-- 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}
+ {#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>
+ <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>