feat: implement PI core chat timeline

This commit is contained in:
2026-08-24 00:34:03 +08:00
parent 54d8bb2e4a
commit 612135f911
40 changed files with 3881 additions and 119 deletions

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