174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
import { LoaderCircle, Plus, RotateCcw, Send, X } from 'lucide-react';
|
||
import { useRef } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { cn } from '@/lib/utils';
|
||
import type { ConversationRunStatus } from '@/types/coding-conversation';
|
||
|
||
export interface CodingComposerProps {
|
||
value: string;
|
||
editable: boolean;
|
||
canSend: boolean;
|
||
preparing: boolean;
|
||
recovering: boolean;
|
||
runStatus: ConversationRunStatus;
|
||
error: string | null;
|
||
recoverableError: boolean;
|
||
acceptedCount: number;
|
||
attachments: Array<{ id: string; name: string; previewUrl: string }>;
|
||
submitting: boolean;
|
||
placeholder: string;
|
||
onChange(value: string): void;
|
||
onSubmit(): void;
|
||
onRecover(): void;
|
||
onAddFiles(files: File[]): void;
|
||
onRemoveAttachment(id: string): void;
|
||
}
|
||
|
||
export function CodingComposer({
|
||
value,
|
||
editable,
|
||
canSend,
|
||
preparing,
|
||
recovering,
|
||
runStatus,
|
||
error,
|
||
recoverableError,
|
||
acceptedCount,
|
||
attachments,
|
||
submitting,
|
||
placeholder,
|
||
onChange,
|
||
onSubmit,
|
||
onRecover,
|
||
onAddFiles,
|
||
onRemoveAttachment,
|
||
}: CodingComposerProps) {
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
||
return (
|
||
<form
|
||
data-testid="coding-message-composer"
|
||
className="relative z-20 mx-auto w-[calc(100%_-_2rem)] max-w-[46rem] shrink-0 pb-4 pt-2 sm:pb-5 sm:pt-3"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (canSend) onSubmit();
|
||
}}
|
||
>
|
||
{(preparing || recovering || submitting || error || acceptedCount > 0 || running) && (
|
||
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-xs text-muted-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.05)]">
|
||
{(preparing || recovering || submitting) && <LoaderCircle className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />}
|
||
<span className="min-w-0 flex-1 text-pretty" role={error ? 'alert' : 'status'}>
|
||
{error
|
||
?? (recovering
|
||
? '正在重新连接本地 Agent…'
|
||
: submitting
|
||
? '正在准备图片并提交…'
|
||
: acceptedCount > 0
|
||
? `${acceptedCount} 条消息已被本地 Agent 接收。`
|
||
: preparing
|
||
? '正在准备本地 Agent,输入框仍可编辑。'
|
||
: running
|
||
? '当前对话正在处理。'
|
||
: '')}
|
||
</span>
|
||
{error && recoverableError && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="min-h-10 shrink-0 rounded-xl px-3 transition-transform duration-150 ease-out active:scale-[0.96]"
|
||
disabled={recovering}
|
||
onClick={onRecover}
|
||
>
|
||
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||
重试
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||
multiple
|
||
className="sr-only"
|
||
data-testid="coding-file-attachment-input"
|
||
onChange={(event) => {
|
||
onAddFiles(Array.from(event.target.files ?? []));
|
||
event.target.value = '';
|
||
}}
|
||
/>
|
||
<div className="chat-composer-surface rounded-2xl bg-background p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.10),0_2px_8px_rgba(0,0,0,0.06)] focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.16),0_4px_14px_rgba(0,0,0,0.07)]">
|
||
{attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 px-1 pb-1" aria-label="已添加到本次消息的图片">
|
||
{attachments.map((attachment) => (
|
||
<div key={attachment.id} className="relative h-16 w-16 overflow-hidden rounded-xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||
<img
|
||
src={attachment.previewUrl}
|
||
alt={attachment.name}
|
||
className="h-full w-full object-cover outline outline-1 -outline-offset-1 outline-black/10"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="absolute right-1 top-1 flex h-10 w-10 translate-x-2 -translate-y-2 items-end justify-start rounded-full bg-foreground/85 p-1.5 text-background transition-[background-color,scale] duration-150 ease-out hover:bg-foreground active:scale-[0.96]"
|
||
aria-label={`移除图片 ${attachment.name}`}
|
||
onClick={() => onRemoveAttachment(attachment.id)}
|
||
>
|
||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Textarea
|
||
value={value}
|
||
rows={1}
|
||
disabled={!editable}
|
||
placeholder={placeholder}
|
||
className="max-h-40 !min-h-[48px] resize-none border-0 bg-transparent px-2 py-2.5 text-sm leading-6 shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return;
|
||
event.preventDefault();
|
||
if (canSend) onSubmit();
|
||
}}
|
||
onPaste={(event) => {
|
||
const files = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/'));
|
||
if (files.length > 0) onAddFiles(files);
|
||
}}
|
||
/>
|
||
<div className="flex min-h-10 items-center justify-between gap-2 px-1">
|
||
<div className="flex min-w-0 items-center gap-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="h-10 w-10 shrink-0 rounded-xl p-0 transition-transform duration-150 ease-out active:scale-[0.96]"
|
||
disabled={!editable || submitting}
|
||
aria-label="添加图片"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
<p className="min-w-0 truncate px-1 text-xs text-muted-foreground">
|
||
{running ? '排队与引导发送将在下一阶段接入' : 'Enter 发送 · Shift + Enter 换行'}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="submit"
|
||
className={cn(
|
||
'h-10 w-10 shrink-0 rounded-xl bg-foreground p-0 text-background shadow-soft hover:bg-foreground/90',
|
||
'transition-transform duration-150 ease-out active:scale-[0.96]',
|
||
)}
|
||
disabled={!canSend}
|
||
aria-label="发送"
|
||
>
|
||
{submitting
|
||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||
: <Send className="h-4 w-4" aria-hidden="true" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|