Files
makelore/src/pages/Chat/CodingComposer.tsx

174 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}