194 lines
6.6 KiB
TypeScript
194 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { ModelInfo } from '@/lib/types';
|
|
|
|
interface ChatInputProps {
|
|
onSend: (content: string, options?: { modelId?: string }) => void;
|
|
onStop?: () => void;
|
|
isStreaming?: boolean;
|
|
models: ModelInfo[];
|
|
selectedModelId: string;
|
|
onModelChange: (modelId: string) => void;
|
|
onRequestEditLastMessage?: () => string | null;
|
|
}
|
|
|
|
const MAX_HEIGHT = 220;
|
|
|
|
export function ChatInput({
|
|
onSend,
|
|
onStop,
|
|
isStreaming = false,
|
|
models,
|
|
selectedModelId,
|
|
onModelChange,
|
|
onRequestEditLastMessage,
|
|
}: ChatInputProps): React.ReactElement {
|
|
const [value, setValue] = useState('');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const selectedModel = useMemo(
|
|
() => models.find((model) => model.id === selectedModelId) ?? models[0],
|
|
[models, selectedModelId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`;
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
function handleGlobalFocus(event: KeyboardEvent): void {
|
|
if (
|
|
(event.metaKey || event.ctrlKey) &&
|
|
(event.key === '/' || event.key.toLowerCase() === 'k')
|
|
) {
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
|
event.preventDefault();
|
|
textareaRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleGlobalFocus);
|
|
return () => document.removeEventListener('keydown', handleGlobalFocus);
|
|
}, []);
|
|
|
|
function handleSubmit(event: React.FormEvent): void {
|
|
event.preventDefault();
|
|
const trimmed = value.trim();
|
|
if (!trimmed || isStreaming) return;
|
|
onSend(trimmed, { modelId: selectedModel?.id });
|
|
setValue('');
|
|
textareaRef.current?.focus();
|
|
}
|
|
|
|
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
handleSubmit(event);
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
|
|
const lastMessage = onRequestEditLastMessage();
|
|
if (lastMessage) {
|
|
event.preventDefault();
|
|
setValue(lastMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
const charCount = value.length;
|
|
const tokenEstimate = Math.ceil(charCount / 4);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div
|
|
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-2)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
|
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]">
|
|
<span className="uppercase tracking-[0.18em]">Model</span>
|
|
<select
|
|
value={selectedModelId}
|
|
onChange={(event) => onModelChange(event.target.value)}
|
|
className="rounded-full border px-3 py-1.5 text-sm outline-none"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
>
|
|
{models.map((model) => (
|
|
<option key={`${model.provider}:${model.id}`} value={model.id}>
|
|
{model.name} · {model.provider}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="ml-auto hidden items-center gap-2 text-xs text-[var(--color-muted)] md:flex">
|
|
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
|
⌘/ focus
|
|
</span>
|
|
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
|
⌘K focus
|
|
</span>
|
|
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
|
⌘↵ send
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-3">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={isStreaming}
|
|
rows={1}
|
|
placeholder="Ask Mosaic something..."
|
|
className="min-h-[3.25rem] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-[var(--color-muted)] disabled:opacity-60"
|
|
style={{
|
|
color: 'var(--color-text)',
|
|
maxHeight: `${MAX_HEIGHT}px`,
|
|
}}
|
|
/>
|
|
|
|
{isStreaming ? (
|
|
<button
|
|
type="button"
|
|
onClick={onStop}
|
|
className="inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-medium transition-colors"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
>
|
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-[var(--color-danger)]" />
|
|
Stop
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={!value.trim()}
|
|
className="inline-flex h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold text-white transition-all disabled:cursor-not-allowed disabled:opacity-45"
|
|
style={{ backgroundColor: 'var(--color-ms-blue-500)' }}
|
|
>
|
|
<span>Send</span>
|
|
<span aria-hidden="true">↗</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
|
|
<span>{charCount.toLocaleString()} chars</span>
|
|
<span>·</span>
|
|
<span>~{tokenEstimate.toLocaleString()} tokens</span>
|
|
{selectedModel ? (
|
|
<>
|
|
<span>·</span>
|
|
<span>{selectedModel.reasoning ? 'Reasoning on' : 'Fast response'}</span>
|
|
</>
|
|
) : null}
|
|
<span className="ml-auto">Shift+Enter newline · Arrow ↑ edit last</span>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|