diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 110eb1ff..a7d2ca34 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; +import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; import { api } from "@/shared/api"; import { useWSEvent } from "@/features/realtime"; import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events"; @@ -100,7 +100,7 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { interface AgentLiveCardProps { issueId: string; agentName?: string; - /** Scroll container ref — needed for sticky sentinel detection. */ + /** Scroll container ref — used to auto-collapse timeline on outer scroll. */ scrollContainerRef?: React.RefObject; } @@ -109,11 +109,11 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL const [activeTask, setActiveTask] = useState(null); const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); + const [open, setOpen] = useState(false); const [autoScroll, setAutoScroll] = useState(true); const [cancelling, setCancelling] = useState(false); - const [isStuck, setIsStuck] = useState(false); const scrollRef = useRef(null); - const sentinelRef = useRef(null); + const ignoreScrollRef = useRef(false); const seenSeqs = useRef(new Set()); // Check for active task on mount @@ -163,46 +163,22 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL }, [issueId]), ); - // Handle task completion/failure - useWSEvent( - "task:completed", - useCallback((payload: unknown) => { - const p = payload as TaskCompletedPayload; - if (p.issue_id !== issueId) return; - setActiveTask(null); - setItems([]); - seenSeqs.current.clear(); - setCancelling(false); - }, [issueId]), - ); + // Handle task completion/failure/cancellation + const handleTaskEnd = useCallback((payload: unknown) => { + const p = payload as { issue_id: string }; + if (p.issue_id !== issueId) return; + setActiveTask(null); + setItems([]); + seenSeqs.current.clear(); + setCancelling(false); + setOpen(false); + }, [issueId]); - useWSEvent( - "task:failed", - useCallback((payload: unknown) => { - const p = payload as TaskFailedPayload; - if (p.issue_id !== issueId) return; - setActiveTask(null); - setItems([]); - seenSeqs.current.clear(); - setCancelling(false); - }, [issueId]), - ); + useWSEvent("task:completed", handleTaskEnd); + useWSEvent("task:failed", handleTaskEnd); + useWSEvent("task:cancelled", handleTaskEnd); - useWSEvent( - "task:cancelled", - useCallback((payload: unknown) => { - const p = payload as TaskCancelledPayload; - if (p.issue_id !== issueId) return; - setActiveTask(null); - setItems([]); - seenSeqs.current.clear(); - setCancelling(false); - }, [issueId]), - ); - - // Pick up new tasks — skip if we're already showing an active task to avoid - // replacing its timeline mid-execution (per-issue serialization in the - // backend prevents this race, but this is a defensive safeguard). + // Pick up new tasks useWSEvent( "task:dispatch", useCallback(() => { @@ -212,6 +188,7 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL setActiveTask(task); setItems([]); seenSeqs.current.clear(); + setOpen(false); } }).catch(console.error); }, [issueId, activeTask]), @@ -226,31 +203,22 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL return () => clearInterval(interval); }, [activeTask?.started_at, activeTask?.dispatched_at]); - // Sentinel pattern: detect when the card is scrolled past and becomes "stuck" + // Auto-collapse timeline when outer scroll container scrolls + // (ignoreScrollRef prevents layout-induced scroll from collapsing right after expand) useEffect(() => { - const sentinel = sentinelRef.current; - const root = scrollContainerRef?.current; - if (!sentinel || !root || !activeTask) { - setIsStuck(false); - return; - } + const container = scrollContainerRef?.current; + if (!container) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]) setIsStuck(!entries[0].isIntersecting); - }, - { root, threshold: 0, rootMargin: "-40px 0px 0px 0px" }, - ); + const handleOuterScroll = () => { + if (ignoreScrollRef.current) return; + setOpen(false); + }; - observer.observe(sentinel); - return () => observer.disconnect(); - }, [scrollContainerRef, activeTask]); + container.addEventListener("scroll", handleOuterScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleOuterScroll); + }, [scrollContainerRef]); - const scrollToCard = useCallback(() => { - sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, []); - - // Auto-scroll + // Auto-scroll timeline to bottom useEffect(() => { if (autoScroll && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; @@ -263,6 +231,14 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL setAutoScroll(scrollHeight - scrollTop - clientHeight < 40); }, []); + const toggleOpen = useCallback(() => { + if (!open) { + ignoreScrollRef.current = true; + setTimeout(() => { ignoreScrollRef.current = false; }, 300); + } + setOpen(!open); + }, [open]); + const handleCancel = useCallback(async () => { if (!activeTask || cancelling) return; setCancelling(true); @@ -280,77 +256,63 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"; return ( - <> - {/* Sentinel — zero-height element that IntersectionObserver watches */} -
- +
+ {/* Header — click to toggle timeline */}
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleOpen(); + } + }} > - {/* Header */} -
- {activeTask.agent_id ? ( - - ) : ( -
- -
- )} -
- - {name} is working + {activeTask.agent_id ? ( + + ) : ( +
+
- {elapsed} - {!isStuck && toolCount > 0 && ( - - {toolCount} tool {toolCount === 1 ? "call" : "calls"} - - )} - {isStuck ? ( - - ) : ( - + )} +
+ + {name} is working + {elapsed} + {toolCount > 0 && ( + {toolCount} tools )}
+
+ + +
+
- {/* Timeline content — collapses when stuck */} -
+ {/* Timeline — grid-rows animation for smooth collapse/expand */} +
+
{items.length > 0 && (
{items.map((item, idx) => ( @@ -358,7 +320,8 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL {!autoScroll && (
- +
); }