- 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]>
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
// 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>
|
||
);
|
||
}
|