Files
beaver-gateway/ui/src/lib/panel/composer.svelte
T

135 lines
3.6 KiB
Svelte

<script lang="ts">
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import { cn } from "$lib/utils";
let {
client,
conversationId,
disabled = false,
}: {
client: ApiClient;
conversationId: string;
disabled?: boolean;
} = $props();
type Mode = "message" | "inject" | "urgent";
const MODES: { value: Mode; label: string; hint: string }[] = [
{ hint: "as you, mirrored to the window", label: "Say", value: "message" },
{
hint: "a system note for the next turn",
label: "Inject",
value: "inject",
},
{ hint: "interrupts the running turn", label: "Urgent", value: "urgent" },
];
const MAX_ROWS = 10;
const LINE_PX = 22;
let mode = $state<Mode>("message");
let text = $state("");
let busy = $state(false);
let area = $state<HTMLTextAreaElement | null>(null);
const current = $derived(MODES.find((m) => m.value === mode) ?? MODES[0]);
function grow() {
if (!area) {
return;
}
area.style.height = "auto";
area.style.height = `${Math.min(area.scrollHeight, LINE_PX * MAX_ROWS)}px`;
}
async function send() {
const body = text.trim();
if (!body || busy) {
return;
}
busy = true;
try {
if (mode === "message") {
await client.postMessage(conversationId, body);
} else {
await client.inject(
conversationId,
body,
mode === "urgent" ? "urgent" : "normal"
);
}
text = "";
queueMicrotask(grow);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
send();
}
}
</script>
<form
class="flex flex-col gap-1.5 px-3 pt-2 pb-2 @md:px-6"
onsubmit={(event) => {
event.preventDefault();
send();
}}
>
<div
class={cn(
"flex items-end gap-2 rounded-xl bg-strip px-3 py-2 shadow-lift ring-1 ring-border focus-within:ring-ring",
disabled && "opacity-60"
)}
>
<textarea
aria-label="Message"
class="max-h-56 min-h-6 w-full resize-none bg-transparent py-0.5 text-sm leading-[22px] outline-none placeholder:text-muted-foreground"
{disabled}
oninput={grow}
onkeydown={onKeydown}
placeholder={disabled
? "This conversation is closed."
: `${current.label} something… (⌘↩)`}
rows="1"
bind:this={area}
bind:value={text}
></textarea>
<button
aria-label="Send"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
disabled={disabled || busy || !text.trim()}
type="submit"
>
<ArrowUpIcon class="size-4" />
</button>
</div>
<div class="flex items-center gap-3 px-1">
{#each MODES as option (option.value)}
<button
class={cn(
"rule-word text-xs",
mode === option.value && "text-foreground",
option.value === "urgent" && mode === option.value && "text-destructive"
)}
data-active={mode === option.value}
onclick={() => {
mode = option.value;
}}
title={option.hint}
type="button"
>
{option.label}
</button>
{/each}
<span class="hidden truncate text-muted-foreground text-xs @md:inline">
{current.hint}
</span>
</div>
</form>