- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话 聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」), 展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。 - agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡, 回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。 - 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠; 「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。 - 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止, / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。 - 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。 - 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。 - 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。 - 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
229 lines
7.9 KiB
TypeScript
229 lines
7.9 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 动作菜单;
|
||
// AGUI-3:输入区只留输入框和发送 / 停止,新增 `/` 指令菜单(commands)与上下文标签(context,可移除);
|
||
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
|
||
import { ArrowUp, Square, X } from "lucide-react";
|
||
import {
|
||
type FormEvent,
|
||
type KeyboardEvent,
|
||
type TextareaHTMLAttributes,
|
||
useCallback,
|
||
useId,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { cn } from "~/lib/utils.ts";
|
||
|
||
export type PromptCommand = { name: string; description: string };
|
||
|
||
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;
|
||
submitLabel?: string;
|
||
stopLabel?: string;
|
||
/** 以 / 开头时弹出的指令菜单 */
|
||
commands?: PromptCommand[];
|
||
/** 上下文标签(对象名称);给了 onClearContext 就带 × */
|
||
context?: string | null;
|
||
onClearContext?: () => void;
|
||
className?: string;
|
||
}
|
||
|
||
/** 行高 24px(text-sm leading-6),与测量层一致 */
|
||
const LINE = 24;
|
||
|
||
/** 正在输入指令(以 / 开头、还没空格)时匹配的指令 */
|
||
export function commandMatches(value: string, commands: PromptCommand[]): PromptCommand[] {
|
||
if (!value.startsWith("/") || /\s/.test(value)) return [];
|
||
return commands.filter((c) => c.name.startsWith(value));
|
||
}
|
||
|
||
export function PromptInput({
|
||
value,
|
||
onValueChange,
|
||
onSubmit,
|
||
loading = false,
|
||
onStop,
|
||
pending = false,
|
||
minRows = 2,
|
||
maxRows = 8,
|
||
submitLabel = "发送",
|
||
stopLabel = "停止",
|
||
commands = [],
|
||
context,
|
||
onClearContext,
|
||
className,
|
||
disabled,
|
||
placeholder,
|
||
"aria-label": ariaLabel = "输入",
|
||
...rest
|
||
}: PromptInputProps) {
|
||
const ta = useRef<HTMLTextAreaElement>(null);
|
||
const measure = useRef<HTMLDivElement>(null);
|
||
const menuId = useId();
|
||
const [active, setActive] = useState(0);
|
||
const [dismissed, setDismissed] = useState<string | null>(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 matches = dismissed === value ? [] : commandMatches(value, commands);
|
||
const menuOpen = matches.length > 0;
|
||
const pick = (c: PromptCommand) => {
|
||
onValueChange(`${c.name} `);
|
||
setActive(0);
|
||
requestAnimationFrame(() => ta.current?.focus());
|
||
};
|
||
|
||
const canSubmit = !!value.trim() && !disabled && !pending;
|
||
const submit = (e?: FormEvent) => {
|
||
e?.preventDefault();
|
||
if (!canSubmit) return;
|
||
onSubmit(value.trim());
|
||
};
|
||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (menuOpen) {
|
||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setActive((i) => (i + (e.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length);
|
||
return;
|
||
}
|
||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
pick(matches[Math.min(active, matches.length - 1)]!);
|
||
return;
|
||
}
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
setDismissed(value);
|
||
return;
|
||
}
|
||
}
|
||
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,
|
||
)}
|
||
>
|
||
{menuOpen ? (
|
||
<div
|
||
id={menuId}
|
||
role="listbox"
|
||
aria-label="指令"
|
||
className="pg-float absolute inset-x-2 bottom-full z-20 mb-1.5 flex flex-col gap-0.5 rounded-xl p-1"
|
||
>
|
||
{matches.map((c, i) => (
|
||
<div
|
||
key={c.name}
|
||
role="option"
|
||
tabIndex={-1}
|
||
aria-selected={i === active}
|
||
// 按下就选(不等 click),避免输入框先失焦
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
pick(c);
|
||
}}
|
||
className={cn(
|
||
"flex w-full cursor-pointer items-baseline gap-2 rounded-lg px-2.5 py-1.5 text-left text-caption outline-none",
|
||
i === active ? "bg-foreground/[0.06] text-foreground" : "text-foreground/85",
|
||
)}
|
||
>
|
||
<span className="shrink-0 font-medium">{c.name}</span>
|
||
<span className="min-w-0 truncate text-meta text-muted-foreground">{c.description}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{context ? (
|
||
<div className="mb-1 flex px-1">
|
||
<span className="inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border border-border/70 py-0.5 pr-1 pl-2 text-meta text-muted-foreground">
|
||
<span className="min-w-0 truncate" title={context}>
|
||
{context}
|
||
</span>
|
||
{onClearContext ? (
|
||
<button
|
||
type="button"
|
||
aria-label={`去掉上下文「${context}」`}
|
||
onClick={onClearContext}
|
||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<X aria-hidden className="size-3" />
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-end gap-1.5">
|
||
<div className="relative min-w-0 flex-1">
|
||
<div
|
||
ref={measure}
|
||
aria-hidden
|
||
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 py-1 text-sm leading-6 break-words whitespace-pre-wrap"
|
||
>
|
||
{`${value}`}
|
||
</div>
|
||
<textarea
|
||
ref={ta}
|
||
value={value}
|
||
disabled={disabled}
|
||
placeholder={placeholder}
|
||
aria-label={ariaLabel}
|
||
aria-autocomplete={commands.length ? "list" : undefined}
|
||
aria-controls={menuOpen ? menuId : undefined}
|
||
rows={minRows}
|
||
{...rest}
|
||
onChange={(e) => {
|
||
onValueChange(e.target.value);
|
||
setActive(0);
|
||
}}
|
||
onKeyDown={onKeyDown}
|
||
className="block w-full resize-none overflow-y-auto bg-transparent px-2 py-1 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|