135 lines
3.6 KiB
Svelte
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>
|