Files
kandev/apps/web/components/task/simple/components/agents-multi-picker.tsx
T
2026-05-16 20:07:57 +01:00

192 lines
6.0 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { IconCheck, IconCircleDashed, IconX } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation";
import { formatRelativeTime } from "@/lib/utils";
import type { AgentProfile } from "@/lib/state/slices/office/types";
import type { Task, TaskDecision } from "@/app/office/tasks/[id]/types";
import { MultiSelectPopover, type MultiSelectItem } from "./multi-select-popover";
type AgentItem = MultiSelectItem & { icon: string; name: string };
// AgentDecisionStatus is the per-chip badge state for an agent listed
// as a reviewer or approver. Computed by buildDecisionLookup below.
export type AgentDecisionStatus = "approved" | "changes_requested" | "pending";
// buildDecisionLookup maps agent id → most-recent decision for the
// given role, restricted to active rows. Used by ReviewersPicker and
// ApproversPicker to render a per-chip status icon.
export function buildDecisionLookup(
decisions: TaskDecision[],
role: TaskDecision["role"],
): Map<string, TaskDecision> {
const out = new Map<string, TaskDecision>();
for (const d of decisions) {
if (d.role !== role) continue;
const prev = out.get(d.deciderId);
if (!prev || prev.createdAt < d.createdAt) {
out.set(d.deciderId, d);
}
}
return out;
}
function statusFromDecision(d: TaskDecision | undefined): AgentDecisionStatus {
if (!d) return "pending";
return d.decision === "approved" ? "approved" : "changes_requested";
}
type DecisionIconProps = {
decision: TaskDecision | undefined;
};
const DECISION_ICON_CLASS: Record<AgentDecisionStatus, string> = {
approved: "h-3 w-3 text-green-600",
changes_requested: "h-3 w-3 text-red-600",
pending: "h-3 w-3 text-muted-foreground",
};
const DECISION_ICON_COMPONENT: Record<AgentDecisionStatus, typeof IconCheck> = {
approved: IconCheck,
changes_requested: IconX,
pending: IconCircleDashed,
};
function decisionTooltip(decision: TaskDecision | undefined): string {
if (!decision) return "No decision yet";
const verb = decision.decision === "approved" ? "Approved" : "Requested changes";
const when = formatRelativeTime(decision.createdAt);
const note = decision.comment ? ` — ${decision.comment}` : "";
return `${verb} ${when}${note}`;
}
function DecisionIcon({ decision }: DecisionIconProps) {
const status = statusFromDecision(decision);
const Icon = DECISION_ICON_COMPONENT[status];
const tip = decisionTooltip(decision);
return (
<Tooltip>
<TooltipTrigger asChild>
<span data-testid={`decision-icon-${status}`} aria-label={tip}>
<Icon className={DECISION_ICON_CLASS[status]} />
</span>
</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
);
}
function buildAgentItems(agents: AgentProfile[]): AgentItem[] {
return agents.map<AgentItem>((a) => ({
id: a.id,
name: a.name,
icon: a.icon ?? "🤖",
label: a.name,
keywords: [a.name, a.role ?? ""],
}));
}
type AgentsMultiPickerProps = {
task: Task;
selectedIds: string[];
fieldKey: keyof Pick<Task, "reviewers" | "approvers">;
addLabel: string;
testId: string;
apiAdd: (taskId: string, agentId: string) => Promise<unknown>;
apiRemove: (taskId: string, agentId: string) => Promise<unknown>;
// Optional map from agent id -> their most recent decision for this
// picker's role. When provided, each chip renders a status icon next
// to the agent name (✓ approved, ✕ changes requested, ◯ pending).
decisionsByAgent?: Map<string, TaskDecision>;
};
export function AgentsMultiPicker({
task,
selectedIds,
fieldKey,
addLabel,
testId,
apiAdd,
apiRemove,
decisionsByAgent,
}: AgentsMultiPickerProps) {
const agents = useAppStore((s) => s.office.agentProfiles);
const mutate = useOptimisticTaskMutation();
const items = useMemo(() => buildAgentItems(agents), [agents]);
const handleAdd = async (id: string) => {
if (selectedIds.includes(id)) return;
const next = [...selectedIds, id];
try {
await mutate(task.id, { [fieldKey]: next } as Partial<Task>, () => apiAdd(task.id, id));
} catch {
/* hook toasts */
}
};
const handleRemove = async (id: string) => {
if (!selectedIds.includes(id)) return;
const next = selectedIds.filter((a) => a !== id);
try {
await mutate(task.id, { [fieldKey]: next } as Partial<Task>, () => apiRemove(task.id, id));
} catch {
/* hook toasts */
}
};
const renderChip = (item: AgentItem, remove: () => void) => (
<span
key={item.id}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs"
>
<span className="text-sm leading-none">{item.icon}</span>
<span className="truncate max-w-[110px]">{item.name}</span>
{decisionsByAgent && <DecisionIcon decision={decisionsByAgent.get(item.id)} />}
<span
role="button"
tabIndex={0}
className="ml-0.5 cursor-pointer opacity-60 hover:opacity-100 inline-flex"
onClick={(e) => {
e.stopPropagation();
remove();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
remove();
}
}}
aria-label={`Remove ${item.name}`}
>
<IconX className="h-2.5 w-2.5" />
</span>
</span>
);
const renderItem = (item: AgentItem) => (
<span className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none">{item.icon}</span>
<span className="truncate">{item.name}</span>
</span>
);
return (
<MultiSelectPopover
items={items}
selectedIds={selectedIds}
onAdd={handleAdd}
onRemove={handleRemove}
renderChip={renderChip}
renderItem={renderItem}
addLabel={addLabel}
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
testId={testId}
/>
);
}