feat(web): W4-F rebuild agent conversation and drawer with agentui components
- Port agentui (MIT, ashish200729/agentui@b2b752b) Message/MessageBubble/MessageScroller, StreamingResponse, ToolResult/ToolApproval, TodoList, ApprovalCard, CodeBlock/FileDiff, Citations, PromptInput into components/agentui; drop motion/shiki/Next, switch colors, type scale, radii, durations and easing to semantic tokens, honour reduced motion - Conversation: streaming cursor, collapsible thinking, tool cards with kind/scope/risk/ idempotency badges and audit idempotency key, prominent server-rejection state for both whitelist rejections and unauthorized errors, compaction markers, budget meters with over-limit state, approval cards (admin session only), two-column final report (model said vs system verified with task/record links), follow-scroll with jump-to-bottom - Composer: Enter send / Shift+Enter newline, stop = abort (confirmed), append, execute/plan_only switch, "execute this plan" from finished plan_only runs - Session list sidebar with status filter and usage; reconnect/error banners with retry - Drawer: route-derived context (entity/site/task), minimize to a live mini dock - Fix stream refetch race when the run switches from loading to finished Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
29cd10b27e
commit
8352e5e881
26 files changed
+3390
-585
No files matched your search
@@ -0,0 +1,137 @@
|
||||
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
|
||||
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
||||
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
|
||||
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等);
|
||||
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export interface PromptInputProps
|
||||
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
onSubmit: (v: string) => void;
|
||||
/** 运行中:发送键变"停止" */
|
||||
loading?: boolean;
|
||||
onStop?: () => void;
|
||||
/** 提交中(禁用发送) */
|
||||
pending?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
/** 输入框下方左侧工具条 */
|
||||
toolbar?: ReactNode;
|
||||
submitLabel?: string;
|
||||
stopLabel?: string;
|
||||
/** 顶部附加内容(如上下文前缀) */
|
||||
header?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 行高 24px(text-sm leading-6),与测量层一致 */
|
||||
const LINE = 24;
|
||||
|
||||
export function PromptInput({
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
loading = false,
|
||||
onStop,
|
||||
pending = false,
|
||||
minRows = 2,
|
||||
maxRows = 8,
|
||||
toolbar,
|
||||
submitLabel = "发送",
|
||||
stopLabel = "停止",
|
||||
header,
|
||||
className,
|
||||
disabled,
|
||||
placeholder,
|
||||
"aria-label": ariaLabel = "输入",
|
||||
...rest
|
||||
}: PromptInputProps) {
|
||||
const ta = useRef<HTMLTextAreaElement>(null);
|
||||
const measure = useRef<HTMLDivElement>(null);
|
||||
const resize = useCallback(() => {
|
||||
const t = ta.current;
|
||||
const m = measure.current;
|
||||
if (!t || !m) return;
|
||||
const h = Math.min(Math.max(m.scrollHeight, minRows * LINE), maxRows * LINE);
|
||||
t.style.height = `${h}px`;
|
||||
}, [minRows, maxRows]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
|
||||
useLayoutEffect(resize, [value, resize]);
|
||||
|
||||
const canSubmit = !!value.trim() && !disabled && !pending;
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(value.trim());
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
submit();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className={cn(
|
||||
"relative w-full min-w-0 rounded-2xl border border-border/80 p-2 transition-colors duration-base focus-within:border-foreground/30",
|
||||
disabled && "opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={measure}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 pt-1.5 text-sm leading-6 break-words whitespace-pre-wrap"
|
||||
>
|
||||
{`${value}`}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ta}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
rows={minRows}
|
||||
{...rest}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex min-h-8 flex-wrap items-center gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{toolbar}</div>
|
||||
{loading && onStop ? (
|
||||
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
|
||||
<Square className="size-3.5" />
|
||||
{stopLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
disabled={!canSubmit}
|
||||
aria-label={submitLabel}
|
||||
title={`${submitLabel}(Enter)`}
|
||||
>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user