Files
next-tjwater-frontend/src/features/agent/components/agent-pending-inputs.tsx
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

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