129 lines
6.5 KiB
TypeScript
129 lines
6.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { LoaderCircle, MoreHorizontal, Pencil, X } from "lucide-react";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import type { AgentInputDelivery, AgentResponseInput } from "../api/client";
|
|
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
|
|
|
|
type AgentPendingInputsProps = {
|
|
presentation: AgentCommandPanelPresentation;
|
|
inputs: AgentResponseInput[];
|
|
onDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
|
|
onEdit?: (id: string) => Promise<void> | void;
|
|
onCancel?: (id: string) => Promise<void> | void;
|
|
};
|
|
|
|
const menuItemClassName = "min-h-10 rounded-lg border border-transparent px-2.5 py-2 text-xs focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)]";
|
|
|
|
export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onEdit, onCancel }: AgentPendingInputsProps) {
|
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
const listRef = useRef<HTMLUListElement | null>(null);
|
|
const latestId = inputs.at(-1)?.id;
|
|
|
|
useEffect(() => {
|
|
const list = listRef.current;
|
|
if (list) list.scrollTop = list.scrollHeight;
|
|
}, [latestId]);
|
|
|
|
const performAction = (id: string, action: () => Promise<void> | void) => {
|
|
setBusyId(id);
|
|
void Promise.resolve().then(action).catch(() => {}).finally(() => setBusyId(null));
|
|
};
|
|
|
|
if (!inputs.length) return null;
|
|
|
|
return (
|
|
<section aria-label="待处理消息队列" className="order-first w-full min-w-0 border-b border-[var(--surface-divider)] bg-[var(--surface-reading)]">
|
|
<p className="px-3 pt-2 text-[11px] leading-5 text-muted-foreground" aria-live="polite">
|
|
等待处理 <span className="ml-1 tabular-nums">{inputs.length}</span>
|
|
</p>
|
|
<ul ref={listRef} aria-label="待处理消息" className="max-h-[min(12rem,28dvh)] divide-y divide-[var(--surface-divider)] overflow-y-auto overscroll-contain px-2 pb-1">
|
|
{inputs.map((input) => (
|
|
<li key={input.id} aria-busy={busyId === input.id} className="flex min-w-0 items-start gap-1 py-1">
|
|
<PendingInputText text={input.text} />
|
|
<span className="shrink-0 py-2 text-[11px] leading-6 text-muted-foreground" title={input.delivery === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}>
|
|
{input.delivery === "queue" ? "排队" : "引导"}
|
|
</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="button" variant="ghost" size="icon" aria-label={`消息操作:${input.text}`}
|
|
disabled={Boolean(busyId)} className="agent-panel-icon-button size-10 shrink-0 active:scale-95 motion-reduce:transform-none">
|
|
{busyId === input.id
|
|
? <LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden />
|
|
: <MoreHorizontal size={16} aria-hidden />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6} collisionPadding={12}
|
|
className="agent-panel-overlay w-48 rounded-xl p-1.5">
|
|
<DropdownMenuItem disabled={!onEdit} className={menuItemClassName}
|
|
onSelect={() => performAction(input.id, () => onEdit?.(input.id))}>
|
|
<Pencil size={14} aria-hidden />编辑消息
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuLabel className="px-2 py-1 text-[11px] font-normal text-muted-foreground">处理方式</DropdownMenuLabel>
|
|
<DropdownMenuRadioGroup aria-label="消息处理方式" value={input.delivery} onValueChange={(delivery) => {
|
|
if (onDeliveryChange && (delivery === "steer" || delivery === "queue")) {
|
|
void Promise.resolve().then(() => onDeliveryChange(input.id, delivery)).catch(() => {});
|
|
}
|
|
}}>
|
|
<DropdownMenuRadioItem value="steer" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>引导当前任务</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="queue" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>排队处理</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem disabled={!onCancel} className={menuItemClassName}
|
|
onSelect={() => performAction(input.id, () => onCancel?.(input.id))}>
|
|
<X size={14} aria-hidden />关闭排队
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PendingInputText({ text }: { text: string }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [overflows, setOverflows] = useState(false);
|
|
const textRef = useRef<HTMLParagraphElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const node = textRef.current;
|
|
if (!node) return;
|
|
const measure = () => {
|
|
setOverflows(node.scrollHeight > Number.parseFloat(getComputedStyle(node).lineHeight) * 2 + 1);
|
|
};
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(node);
|
|
measure();
|
|
return () => observer.disconnect();
|
|
}, [text]);
|
|
|
|
useEffect(() => {
|
|
if (expanded) textRef.current?.parentElement?.scrollIntoView({ block: "nearest" });
|
|
}, [expanded]);
|
|
|
|
return (
|
|
<div className="min-w-0 flex-1 px-1 py-2">
|
|
<p ref={textRef} className={cn("text-sm leading-6 text-foreground [overflow-wrap:anywhere]", expanded ? "whitespace-pre-wrap" : "whitespace-normal line-clamp-2")}>
|
|
{text}
|
|
</p>
|
|
{overflows ? <button type="button" aria-expanded={expanded} onClick={() => setExpanded((current) => !current)}
|
|
className="relative mt-1 rounded-sm text-[11px] leading-5 text-muted-foreground underline underline-offset-4 after:absolute after:-inset-y-2.5 after:inset-x-0 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:opacity-70">
|
|
{expanded ? "收起全文" : "展开全文"}
|
|
</button> : null}
|
|
</div>
|
|
);
|
|
}
|