feat: implement PI core chat timeline
This commit is contained in:
173
src/pages/Chat/CodingComposer.tsx
Normal file
173
src/pages/Chat/CodingComposer.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user