feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user