From 4353340ea68a9e9df7c44f43f6fdd5457572a6fe Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:05:35 +0800 Subject: [PATCH] feat(issues): sticky mini bar for agent live card + toast icon colors Agent live card now uses the sentinel pattern to detect when it scrolls out of view. When stuck, it collapses to a compact header bar with brand styling and backdrop blur, with a ChevronUp button to scroll back. When scrolled back into view, the card seamlessly expands to full view. Also adds semantic colors to Sonner toast icons (success/info/warning/ error/loading) and fixes icon-to-text alignment in toasts globally. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/custom.css | 9 + apps/web/components/ui/sonner.tsx | 10 +- .../issues/components/agent-live-card.tsx | 175 ++++++++++++------ .../issues/components/issue-detail.tsx | 11 +- 4 files changed, 137 insertions(+), 68 deletions(-) diff --git a/apps/web/app/custom.css b/apps/web/app/custom.css index 92c207a4..d24ef7a0 100644 --- a/apps/web/app/custom.css +++ b/apps/web/app/custom.css @@ -30,3 +30,12 @@ background-color: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } + +/* Sonner toast: align icon to first line of text, not vertically centered */ +[data-sonner-toast] { + align-items: flex-start !important; +} + +[data-sonner-toast] [data-icon] { + margin-top: 2.5px; +} diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx index 9280ee52..cb49e93b 100644 --- a/apps/web/components/ui/sonner.tsx +++ b/apps/web/components/ui/sonner.tsx @@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => { className="toaster group" icons={{ success: ( - + ), info: ( - + ), warning: ( - + ), error: ( - + ), loading: ( - + ), }} style={ diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 631fea29..173e0d40 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, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; +import { Bot, ChevronRight, ChevronUp, 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"; @@ -99,16 +99,20 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { interface AgentLiveCardProps { issueId: string; agentName?: string; + /** Scroll container ref — needed for sticky sentinel detection. */ + scrollContainerRef?: React.RefObject; } -export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { +export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) { const { getActorName } = useActorName(); const [activeTask, setActiveTask] = useState(null); const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); const [autoScroll, setAutoScroll] = useState(true); const [cancelling, setCancelling] = useState(false); + const [isStuck, setIsStuck] = useState(false); const scrollRef = useRef(null); + const sentinelRef = useRef(null); const seenSeqs = useRef(new Set()); // Check for active task on mount @@ -215,12 +219,36 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { // Elapsed time useEffect(() => { if (!activeTask?.started_at && !activeTask?.dispatched_at) return; - const ref = activeTask.started_at ?? activeTask.dispatched_at!; - setElapsed(formatElapsed(ref)); - const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000); + const startRef = activeTask.started_at ?? activeTask.dispatched_at!; + setElapsed(formatElapsed(startRef)); + const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000); return () => clearInterval(interval); }, [activeTask?.started_at, activeTask?.dispatched_at]); + // Sentinel pattern: detect when the card is scrolled past and becomes "stuck" + useEffect(() => { + const sentinel = sentinelRef.current; + const root = scrollContainerRef?.current; + if (!sentinel || !root || !activeTask) { + setIsStuck(false); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]) setIsStuck(!entries[0].isIntersecting); + }, + { root, threshold: 0, rootMargin: "-40px 0px 0px 0px" }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [scrollContainerRef, activeTask]); + + const scrollToCard = useCallback(() => { + sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, []); + // Auto-scroll useEffect(() => { if (autoScroll && scrollRef.current) { @@ -248,67 +276,100 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { if (!activeTask) return null; const toolCount = items.filter((i) => i.type === "tool_use").length; + const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"; return ( -
- {/* Header */} -
-
- -
-
- - {(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working -
- {elapsed} - {toolCount > 0 && ( - - {toolCount} tool {toolCount === 1 ? "call" : "calls"} - + <> + {/* Sentinel — zero-height element that IntersectionObserver watches */} +
+ +
- {cancelling ? ( - - ) : ( - + > + {/* Header */} +
+
+ +
+
+ + {name} is working +
+ {elapsed} + {!isStuck && toolCount > 0 && ( + + {toolCount} tool {toolCount === 1 ? "call" : "calls"} + )} - Stop - -
- - {/* Timeline content */} - {items.length > 0 && ( -
- {items.map((item, idx) => ( - - ))} - - {!autoScroll && ( + {isStuck ? ( + ) : ( + )}
- )} -
+ + {/* Timeline content — collapses when stuck */} +
+ {items.length > 0 && ( +
+ {items.map((item, idx) => ( + + ))} + + {!autoScroll && ( + + )} +
+ )} +
+
+ ); } diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 4343a2ba..8b3687b7 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -771,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Agent live output */} -
- -
+ {/* Agent execution history */}