122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useEffect, useId, useRef } from "react";
|
||
|
|
import type { FormEvent, RefObject } from "react";
|
||
|
|
import { Send, Square } from "lucide-react";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import type { AIMode } from "./workspace-mode";
|
||
|
|
import { submitComposer } from "./composer-events";
|
||
|
|
|
||
|
|
export function shouldSubmitOnEnter(event: {
|
||
|
|
key: string;
|
||
|
|
shiftKey: boolean;
|
||
|
|
isComposing: boolean;
|
||
|
|
}): boolean {
|
||
|
|
return event.key === "Enter" && !event.shiftKey && !event.isComposing;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface WorkspaceComposerProps {
|
||
|
|
mode: AIMode;
|
||
|
|
input: string;
|
||
|
|
onInputChange: (value: string) => void;
|
||
|
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||
|
|
busy?: boolean;
|
||
|
|
onStop?: () => void;
|
||
|
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||
|
|
active?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function WorkspaceComposer({
|
||
|
|
mode,
|
||
|
|
input,
|
||
|
|
onInputChange,
|
||
|
|
onSubmit,
|
||
|
|
busy = false,
|
||
|
|
onStop,
|
||
|
|
inputRef,
|
||
|
|
active = true,
|
||
|
|
}: WorkspaceComposerProps) {
|
||
|
|
const localRef = useRef<HTMLTextAreaElement>(null);
|
||
|
|
const ref = inputRef ?? localRef;
|
||
|
|
const hintId = useId();
|
||
|
|
const isChat = mode === "chat";
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!active || !ref.current) return;
|
||
|
|
ref.current.style.height = "auto";
|
||
|
|
ref.current.style.height = `${Math.min(ref.current.scrollHeight, 200)}px`;
|
||
|
|
}, [input, active, ref]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="shrink-0 border-t bg-background px-4 py-4">
|
||
|
|
<form
|
||
|
|
aria-label={isChat ? "Chat composer" : "Agent builder composer"}
|
||
|
|
onSubmit={(event) => {
|
||
|
|
submitComposer(event, input, busy, onSubmit, () =>
|
||
|
|
ref.current?.focus(),
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
className="mx-auto flex max-w-3xl items-end gap-3"
|
||
|
|
>
|
||
|
|
<textarea
|
||
|
|
ref={ref}
|
||
|
|
data-composer-mode={mode}
|
||
|
|
value={input}
|
||
|
|
onChange={(event) => onInputChange(event.target.value)}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (
|
||
|
|
shouldSubmitOnEnter({
|
||
|
|
key: event.key,
|
||
|
|
shiftKey: event.shiftKey,
|
||
|
|
isComposing: event.nativeEvent.isComposing,
|
||
|
|
})
|
||
|
|
) {
|
||
|
|
event.preventDefault();
|
||
|
|
event.currentTarget.form?.requestSubmit();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
placeholder={isChat ? "Ask anything…" : "Describe a reusable agent…"}
|
||
|
|
aria-label={isChat ? "Chat message" : "Agent instructions"}
|
||
|
|
aria-describedby={hintId}
|
||
|
|
rows={1}
|
||
|
|
readOnly={busy}
|
||
|
|
className="max-h-[200px] min-h-[44px] flex-1 resize-none rounded-xl border bg-muted/30 px-4 py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring read-only:opacity-60"
|
||
|
|
/>
|
||
|
|
{busy && onStop ? (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="icon"
|
||
|
|
variant="outline"
|
||
|
|
className="h-11 w-11 shrink-0 rounded-xl"
|
||
|
|
aria-label="Stop generating"
|
||
|
|
onClick={() => {
|
||
|
|
onStop();
|
||
|
|
ref.current?.focus();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Square className="size-4" aria-hidden="true" />
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
<Button
|
||
|
|
type="submit"
|
||
|
|
size="icon"
|
||
|
|
className="h-11 w-11 shrink-0 rounded-xl"
|
||
|
|
disabled={busy || !input.trim()}
|
||
|
|
aria-label={isChat ? "Send message" : "Check builder availability"}
|
||
|
|
>
|
||
|
|
<Send className="size-4" aria-hidden="true" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</form>
|
||
|
|
<p
|
||
|
|
id={hintId}
|
||
|
|
className="mx-auto mt-2 max-w-3xl text-center text-[10px] text-muted-foreground"
|
||
|
|
>
|
||
|
|
{isChat ? "AI can make mistakes. " : "Builder is not connected yet. "}
|
||
|
|
Enter to send · Shift+Enter for a new line.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|