From 2ed9408ed385f14940c7c19472645a1ade1d0d3a Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Thu, 7 May 2026 12:35:22 -0700 Subject: [PATCH] =?UTF-8?q?ui(v2=20managed=20agents):=20polish=20sessions?= =?UTF-8?q?=20UI=20=E2=80=94=20left=20rail,=20refined=20typography,=20dev-?= =?UTF-8?q?tool=20aesthetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sessions list as left rail (was full-page list view) - group sessions by Today/This Week/Last Week/Older - active row uses subtle bg (was 2px border) - status dots only render for non-ready states - header shows agent name from agentNameById fallback - composer in rounded bordered container with consolidated footer - stop button is filled-dark SVG square (was bold ascii character) - markdown sizes tightened (h1 17px, h2 15px, h3 13.5px, body 13.5/1.6) - user messages render as bold quote-headers with hairline separator - redirect /sessions to latest session --- .../src/app/sessions/[sid]/page.tsx | 8 + .../src/app/sessions/[sid]/view.tsx | 782 ++++++++++++++++++ .../src/app/sessions/layout.tsx | 354 ++++++++ .../src/app/sessions/page.tsx | 57 ++ 4 files changed, 1201 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx create mode 100644 ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx create mode 100644 ui/litellm-dashboard/src/app/sessions/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/sessions/page.tsx diff --git a/ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx b/ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx new file mode 100644 index 00000000000..05585680bd5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import React from "react"; +import SessionThreadView from "./view"; + +export default function SessionThreadPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx b/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx new file mode 100644 index 00000000000..b517755934e --- /dev/null +++ b/ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx @@ -0,0 +1,782 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useParams, useRouter } from "next/navigation"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type MessageStatus = "in_progress" | "completed" | "failed"; +type MessageRole = "user" | "assistant"; + +interface ToolCall { + name: string; + input?: unknown; + output?: string; +} + +interface MessageRow { + id: string; + session_id: string; + role: MessageRole; + content: string; + status: MessageStatus; + created_at: string; + completed_at?: string; + tools?: ToolCall[]; + model?: string; + error_reason?: string; +} + +interface SandboxSpec { + type: string; + size: string; + timeout_minutes?: number; + idle_timeout_minutes?: number; +} + +interface RepoSpec { + url: string; + starting_ref: string; + checked_out_sha?: string; +} + +interface SessionRow { + id: string; + agent_id: string; + agent_name?: string; + sandbox: SandboxSpec; + status: string; + repos: RepoSpec[]; + created_by: string; + created_at: string; + terminated_at: string | null; + default_model?: string; +} + +interface ListResponse { + data: T[]; + next_cursor: string | null; + has_more: boolean; +} + +const DEFAULT_PROXY = "http://localhost:4000"; +const DEFAULT_KEY = "sk-1234"; +const POLL_INTERVAL_MS = 2000; +const COLUMN_MAX_WIDTH = 720; + +const getProxyBase = (): string => + (typeof window !== "undefined" && + localStorage.getItem("LITELLM_PROXY_URL")) || + DEFAULT_PROXY; + +const getApiKey = (): string => + (typeof window !== "undefined" && localStorage.getItem("LITELLM_API_KEY")) || + DEFAULT_KEY; + +const buildHeaders = (): HeadersInit => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${getApiKey()}`, +}); + +const truncate = (s: string, n: number): string => + s.length > n ? s.slice(0, n) + "…" : s; + +const formatRelative = (iso: string | null | undefined): string => { + if (!iso) return ""; + try { + const d = new Date(iso).getTime(); + const now = Date.now(); + const diff = Math.max(0, now - d); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h`; + const day = Math.floor(hr / 24); + if (day < 7) return `${day}d`; + const wk = Math.floor(day / 7); + if (wk < 5) return `${wk}w`; + const mo = Math.floor(day / 30); + if (mo < 12) return `${mo}mo`; + return `${Math.floor(day / 365)}y`; + } catch { + return ""; + } +}; + +const statusColor = (status: string): string => { + switch ((status || "").toLowerCase()) { + case "error": + case "failed": + return "#dc2626"; + case "provisioning": + case "pending": + case "queued": + return "#d97706"; + default: + // ready, terminated, anything else — subtle gray + return "#bcbcc0"; + } +}; + +const groupSessionsByAge = ( + sessions: SessionRow[], +): Array<{ label: string; items: SessionRow[] }> => { + const now = Date.now(); + const day = 1000 * 60 * 60 * 24; + const buckets: Record = { + Today: [], + "This Week": [], + "Last Week": [], + Older: [], + }; + for (const s of sessions) { + const age = now - new Date(s.created_at).getTime(); + if (age < day) buckets.Today.push(s); + else if (age < 7 * day) buckets["This Week"].push(s); + else if (age < 14 * day) buckets["Last Week"].push(s); + else buckets.Older.push(s); + } + return [ + { label: "Today", items: buckets.Today }, + { label: "This Week", items: buckets["This Week"] }, + { label: "Last Week", items: buckets["Last Week"] }, + { label: "Older", items: buckets.Older }, + ].filter((g) => g.items.length > 0); +}; + +export default function SessionThreadView() { + const params = useParams<{ sid: string }>(); + const router = useRouter(); + const sessionId = params?.sid || ""; + + const [session, setSession] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [aborting, setAborting] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sessionsList, setSessionsList] = useState([]); + const [agentNameById, setAgentNameById] = useState>( + {}, + ); + + const messagesEndRef = useRef(null); + + // Fetch sessions + agents (for the left rail) + useEffect(() => { + let cancelled = false; + (async () => { + const proxy = getProxyBase(); + const headers = buildHeaders(); + try { + const [sRes, aRes] = await Promise.all([ + fetch(`${proxy}/v2/sessions?limit=100`, { headers }), + fetch(`${proxy}/v2/agents?limit=100`, { headers }), + ]); + if (cancelled) return; + if (sRes.ok) { + const data: ListResponse = await sRes.json(); + setSessionsList(data.data || []); + } + if (aRes.ok) { + const data: ListResponse<{ id: string; name: string }> = + await aRes.json(); + const map: Record = {}; + for (const a of data.data || []) map[a.id] = a.name; + setAgentNameById(map); + } + } catch { + // silent — rail is non-critical + } + })(); + return () => { + cancelled = true; + }; + }, [sessionId]); // refresh rail when nav changes + + const hasInProgress = useMemo( + () => messages.some((m) => m.status === "in_progress"), + [messages], + ); + + const currentModel = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === "assistant" && m.model) return m.model; + } + return session?.default_model || ""; + }, [messages, session]); + + const loadSession = useCallback(async () => { + if (!sessionId) return; + setLoading(true); + setError(null); + try { + const proxy = getProxyBase(); + const headers = buildHeaders(); + const [sessionRes, messagesRes] = await Promise.all([ + fetch(`${proxy}/v2/sessions/${sessionId}`, { headers }), + fetch(`${proxy}/v2/sessions/${sessionId}/messages`, { headers }), + ]); + + if (sessionRes.ok) { + setSession(await sessionRes.json()); + } else { + throw new Error(`Failed to fetch session: ${sessionRes.status}`); + } + + if (messagesRes.ok) { + const m: ListResponse = await messagesRes.json(); + setMessages(m.data || []); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [sessionId]); + + useEffect(() => { + loadSession(); + }, [loadSession]); + + // Poll while any message is in_progress + useEffect(() => { + if (!sessionId || !hasInProgress) return; + let cancelled = false; + const interval = setInterval(async () => { + try { + const res = await fetch( + `${getProxyBase()}/v2/sessions/${sessionId}/messages`, + { headers: buildHeaders() }, + ); + if (!res.ok || cancelled) return; + const m: ListResponse = await res.json(); + if (!cancelled) setMessages(m.data || []); + } catch { + // silent + } + }, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [sessionId, hasInProgress]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }, [messages]); + + const handleSend = useCallback(async () => { + const content = draft.trim(); + if (!content || !sessionId || sending) return; + setSending(true); + setError(null); + + const optimisticId = `optimistic-${Date.now()}`; + const optimistic: MessageRow = { + id: optimisticId, + session_id: sessionId, + role: "user", + content, + status: "completed", + created_at: new Date().toISOString(), + }; + setMessages((prev) => [...prev, optimistic]); + setDraft(""); + + try { + const res = await fetch( + `${getProxyBase()}/v2/sessions/${sessionId}/messages`, + { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify({ content }), + }, + ); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`${res.status} ${errText || res.statusText}`); + } + const refreshed = await fetch( + `${getProxyBase()}/v2/sessions/${sessionId}/messages`, + { headers: buildHeaders() }, + ); + if (refreshed.ok) { + const m: ListResponse = await refreshed.json(); + setMessages(m.data || []); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setMessages((prev) => prev.filter((x) => x.id !== optimisticId)); + } finally { + setSending(false); + } + }, [draft, sessionId, sending]); + + const handleAbort = useCallback(async () => { + if (!sessionId || aborting) return; + setAborting(true); + setError(null); + try { + const res = await fetch( + `${getProxyBase()}/v2/sessions/${sessionId}/abort`, + { method: "POST", headers: buildHeaders() }, + ); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`${res.status} ${errText || res.statusText}`); + } + const refreshed = await fetch( + `${getProxyBase()}/v2/sessions/${sessionId}/messages`, + { headers: buildHeaders() }, + ); + if (refreshed.ok) { + const m: ListResponse = await refreshed.json(); + setMessages(m.data || []); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setAborting(false); + } + }, [sessionId, aborting]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + return ( +
+ router.push(`/sessions/${id}`)} + /> + +
+
+ + {session?.agent_name || + (session ? agentNameById[session.agent_id] : "") || + "Session"} + + {session?.status && ( + + {session.status !== "ready" && ( + + )} + {session.status} + + )} +
+
+ +
+
+ {loading && messages.length === 0 && ( +
+ Loading… +
+ )} + {!loading && messages.length === 0 && ( +
+ No messages. Send one below. +
+ )} + {messages.map((m) => ( + + ))} +
+
+
+ +
+
+
+