From 07315c47e9b9612d2d831cc8ed3778cb4b9e0076 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Thu, 7 May 2026 12:55:14 -0700 Subject: [PATCH] ui(v2 managed agents): live updates, sticky composer, Enter sends, queued state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: removed nav items (New Agent, Automations, Dashboard, Bugbot) - Search placeholder: 'Search sessions ⌘K' - Composer is sticky at the bottom (outside scroll container) so the thread scrolls cleanly - Enter sends; Shift+Enter inserts a newline (Cmd+Enter no longer required) - Optimistic user message renders with a 'queued' spinner badge until the server confirms - Always poll /v2/sessions every 5s so externally-created sessions appear in the rail without reload - Always poll /messages (5s when idle, 2s when in_progress) so externally-sent messages appear without reload - Auto-scroll only triggers when user is already near the bottom (no hijacking) --- .../src/app/sessions/[sid]/view.tsx | 157 +++++++++++------- 1 file changed, 96 insertions(+), 61 deletions(-) diff --git a/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx b/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx index 09b34813edd..771a0167d0d 100644 --- a/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx +++ b/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx @@ -13,10 +13,6 @@ import remarkGfm from "remark-gfm"; import { PanelLeft, Search, - SquarePen, - Workflow, - Home, - Bug, Circle, ChevronDown, ChevronRight, @@ -27,6 +23,7 @@ import { ArrowUp, Square, Image as ImageIcon, + Loader2, } from "lucide-react"; type MessageStatus = "in_progress" | "completed" | "failed"; @@ -172,8 +169,10 @@ export default function SessionThreadView() { const [agentNameById, setAgentNameById] = useState>( {}, ); + const [pendingMessageId, setPendingMessageId] = useState(null); const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); const hasInProgress = useMemo( () => messages.some((m) => m.status === "in_progress"), @@ -228,10 +227,11 @@ export default function SessionThreadView() { loadSession(); }, [loadSession]); - // Load all sessions + agents for the rail + // Load all sessions + agents for the rail; poll every 5s so externally-created + // sessions (e.g. curl POST /v2/sessions) appear without reload. useEffect(() => { let cancelled = false; - (async () => { + const fetchAll = async () => { const proxy = getProxyBase(); const headers = buildHeaders(); try { @@ -253,17 +253,21 @@ export default function SessionThreadView() { } catch { // silent } - })(); + }; + fetchAll(); + const interval = setInterval(fetchAll, 5000); return () => { cancelled = true; + clearInterval(interval); }; }, [sessionId]); - // Poll while any message is in_progress + // Always poll messages (so externally-sent messages appear without reload). + // Faster when something is in_progress; slower when idle. useEffect(() => { - if (!sessionId || !hasInProgress) return; + if (!sessionId) return; let cancelled = false; - const interval = setInterval(async () => { + const fetchMessages = async () => { try { const res = await fetch( `${getProxyBase()}/v2/sessions/${sessionId}/messages`, @@ -275,18 +279,31 @@ export default function SessionThreadView() { } catch { // silent } - }, POLL_INTERVAL_MS); + }; + const intervalMs = hasInProgress ? POLL_INTERVAL_MS : 5000; + const interval = setInterval(fetchMessages, intervalMs); return () => { cancelled = true; clearInterval(interval); }; }, [sessionId, hasInProgress]); + // Auto-scroll only when user is already near the bottom; don't hijack scroll + const lastMessageCountRef = useRef(0); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ - behavior: "smooth", - block: "end", - }); + const c = scrollContainerRef.current; + if (!c) return; + const newCount = messages.length; + const grew = newCount > lastMessageCountRef.current; + lastMessageCountRef.current = newCount; + const distanceFromBottom = c.scrollHeight - c.scrollTop - c.clientHeight; + const nearBottom = distanceFromBottom < 200; + if (grew && nearBottom) { + messagesEndRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + } }, [messages]); const handleSend = useCallback(async () => { @@ -301,10 +318,11 @@ export default function SessionThreadView() { session_id: sessionId, role: "user", content, - status: "completed", + status: "in_progress", // queued — server hasn't confirmed yet created_at: new Date().toISOString(), }; setMessages((prev) => [...prev, optimistic]); + setPendingMessageId(optimisticId); setDraft(""); try { @@ -328,9 +346,11 @@ export default function SessionThreadView() { const m: ListResponse = await refreshed.json(); setMessages(m.data || []); } + setPendingMessageId(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setMessages((prev) => prev.filter((x) => x.id !== optimisticId)); + setPendingMessageId(null); } finally { setSending(false); } @@ -361,7 +381,8 @@ export default function SessionThreadView() { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + // Enter sends (no modifiers). Shift+Enter inserts a newline. + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } @@ -369,6 +390,8 @@ export default function SessionThreadView() { [handleSend], ); + const isQueued = pendingMessageId !== null || sending; + return (
); @@ -424,20 +449,12 @@ function Sidebar({
- Search agents ⌘K + Search sessions ⌘K
- {/* Main nav */} -
- } label="New Agent" /> - } label="Automations" /> - } label="Dashboard" /> - } label="Bugbot" /> -
- {/* Session groups */} -
+
{groups.length === 0 && (
No sessions yet.
)} @@ -485,15 +502,6 @@ function Sidebar({ ); } -function NavItem({ icon, label }: { icon: React.ReactNode; label: string }) { - return ( -
-
{icon}
- {label} -
- ); -} - function ActiveItem({ label, status }: { label: string; status: string }) { const dotColor = status === "ready" @@ -550,6 +558,8 @@ interface MainPanelProps { handleAbort: () => void; handleKeyDown: (e: React.KeyboardEvent) => void; messagesEndRef: React.RefObject; + scrollContainerRef: React.RefObject; + pendingMessageId: string | null; } function MainPanel({ @@ -568,13 +578,15 @@ function MainPanel({ handleAbort, handleKeyDown, messagesEndRef, + scrollContainerRef, + pendingMessageId, }: MainPanelProps) { const repoLabel = session?.repos?.[0]?.url ? session.repos[0].url.replace(/^https?:\/\/github\.com\//, "") : "BerriAI/litellm"; return ( -
+
{/* Header */}
@@ -597,8 +609,11 @@ function MainPanel({
- {/* Scrollable thread */} -
+ {/* Scrollable thread (composer is OUTSIDE this scroll) */} +
{loading && messages.length === 0 && (
Loading…
@@ -617,10 +632,18 @@ function MainPanel({ m.role === "user" && messages.slice(0, i).every((x) => x.role !== "user") } + isPending={m.id === pendingMessageId} /> ))} - {/* Composer */} +
+
+
+
+ + {/* Sticky composer */} +
+
- -
-
@@ -645,31 +665,46 @@ function MainPanel({ function MessageBlock({ msg, isFirstUser, + isPending, }: { msg: MessageRow; isFirstUser: boolean; + isPending: boolean; }) { if (msg.role === "user") { - if (isFirstUser) { - return ; - } - return ; + return ( + + ); } return ; } -function InitialPromptBlock({ content }: { content: string }) { +function UserPromptBlock({ + content, + emphasized, + pending, +}: { + content: string; + emphasized: boolean; + pending: boolean; +}) { return ( -
- {content} -
- ); -} - -function UserFollowupBlock({ content }: { content: string }) { - return ( -
+
{content} + {pending && ( +
+ + queued +
+ )}
); } @@ -803,7 +838,7 @@ function Composer({ const canSend = draft.trim().length > 0 && !sending; return ( -
+