"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Check, X as XIcon } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { useWSEvent } from "@multica/core/realtime"; import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store"; import { useWorkspacePaths } from "@multica/core/paths"; import { useNavigation } from "../../navigation"; import { stripQuickCreatePrefix } from "../../inbox/components/inbox-display"; import { ActorAvatar } from "../../common/actor-avatar"; import type { InboxNewPayload } from "@multica/core/types"; interface ResolvedItem { taskId: string; agentId: string; agentName: string; result: | { type: "done"; issueId: string; identifier: string; title: string } | { type: "failed"; error: string }; exiting: boolean; } const DONE_VISIBLE_MS = 3000; const FAILED_VISIBLE_MS = 5000; const EXIT_ANIMATION_MS = 300; /** * Stacked circular indicators above the Chat FAB showing in-flight * quick-create tasks. Each pill shows the agent avatar with a spinning * ring while pending, and transitions to a success/failure state when * the `inbox:new` WS event arrives. * * Hover expands the pill to reveal the agent name / result text. */ export function QuickCreateStack() { const pendingTasks = useQuickCreateStore((s) => s.pendingTasks); const removePendingTask = useQuickCreateStore((s) => s.removePendingTask); const paths = useWorkspacePaths(); const navigation = useNavigation(); const [resolved, setResolved] = useState>({}); const timersRef = useRef>>(new Map()); // Schedule auto-removal of a resolved item. const scheduleRemoval = useCallback((taskId: string, delayMs: number) => { // Phase 1: mark as exiting (triggers fade-out animation) const exitTimer = setTimeout(() => { setResolved((prev) => { const item = prev[taskId]; if (!item) return prev; return { ...prev, [taskId]: { ...item, exiting: true } }; }); // Phase 2: remove from state after animation completes const removeTimer = setTimeout(() => { setResolved((prev) => { const { [taskId]: _, ...rest } = prev; return rest; }); timersRef.current.delete(taskId); }, EXIT_ANIMATION_MS); timersRef.current.set(taskId, removeTimer); }, delayMs); timersRef.current.set(taskId, exitTimer); }, []); // Clean up timers on unmount. useEffect(() => { return () => { timersRef.current.forEach(clearTimeout); }; }, []); // Listen for quick-create inbox events. const handler = useCallback( (payload: unknown) => { const { item } = payload as InboxNewPayload; if (!item) return; const taskId = item.details?.task_id; if (!taskId) return; const pending = useQuickCreateStore.getState().pendingTasks[taskId]; if (!pending) return; if (item.type === "quick_create_done") { const identifier = item.details?.identifier ?? ""; const title = stripQuickCreatePrefix(item.title, identifier); setResolved((prev) => ({ ...prev, [taskId]: { taskId, agentId: pending.agentId, agentName: pending.agentName, result: { type: "done", issueId: item.issue_id ?? "", identifier, title: title || "Issue created", }, exiting: false, }, })); removePendingTask(taskId); scheduleRemoval(taskId, DONE_VISIBLE_MS); } else if (item.type === "quick_create_failed") { const error = item.details?.error || item.body || "Quick create did not finish"; setResolved((prev) => ({ ...prev, [taskId]: { taskId, agentId: pending.agentId, agentName: pending.agentName, result: { type: "failed", error }, exiting: false, }, })); removePendingTask(taskId); scheduleRemoval(taskId, FAILED_VISIBLE_MS); } }, [removePendingTask, scheduleRemoval], ); useWSEvent("inbox:new", handler); // Merge pending + resolved into a single render list. const pendingItems = Object.values(pendingTasks); const resolvedItems = Object.values(resolved); const allItems = [...pendingItems.map((t) => ({ ...t, resolved: null as ResolvedItem | null })), ...resolvedItems.map((r) => ({ taskId: r.taskId, agentId: r.agentId, agentName: r.agentName, prompt: "", resolved: r as ResolvedItem | null }))]; if (allItems.length === 0) return null; return (
{allItems.map((item) => { const isDone = item.resolved?.result.type === "done"; const isFailed = item.resolved?.result.type === "failed"; const isPending = !item.resolved; const isExiting = item.resolved?.exiting ?? false; const handleClick = () => { if (isDone && item.resolved?.result.type === "done") { const issueId = item.resolved.result.issueId; if (issueId) navigation.push(paths.issueDetail(issueId)); } }; return (
{/* Avatar circle */}
{/* Subtle pulsing ring for pending */} {isPending && (
)} {/* Success ring + icon */} {isDone && ( <>
)} {/* Failure ring + icon */} {isFailed && ( <>
)}
{/* Expanded label (visible on hover) */} {isPending && `${item.agentName} is creating…`} {isDone && item.resolved?.result.type === "done" && ( {item.resolved.result.identifier} {" created"} )} {isFailed && "Failed to create issue"}
); })}
); }