From 35262d2aab05d145bc26dfeef6cd3e99af8a2de3 Mon Sep 17 00:00:00 2001 From: xiejin Date: Wed, 1 Apr 2026 19:24:14 +0800 Subject: [PATCH] feat(web): show session status indicators in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual dot indicators after the timestamp in the session list: - busy: green pulsing dot (animate-pulse) - unread: blue static dot (completed while user was viewing another session) - idle: no dot (default) Track unread sessions via busy→idle transitions from both WebSocket status events and API refresh polling. Clear unread on session select. --- web/src/App.tsx | 57 +++++++++++++++++++++++++- web/src/features/sessions/sessions.tsx | 45 ++++++++++++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index f387c6c57..5408b105b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -102,6 +102,10 @@ function App() { const [streamStatus, setStreamStatus] = useState("ready"); + // Track sessions with unread results (completed a turn while user wasn't viewing) + const [unreadSessionIds, setUnreadSessionIds] = useState>(new Set()); + const prevSessionStatusRef = useRef>(new Map()); + useEffect(() => { const token = consumeAuthTokenFromUrl(); if (token) { @@ -266,10 +270,48 @@ function App() { setStreamStatus(nextStatus); }, []); + // Detect busy→idle transitions from API refresh to mark sessions as unread + useEffect(() => { + const prev = prevSessionStatusRef.current; + const next = new Map(); + const newUnread: string[] = []; + + for (const session of sessions) { + const state = session.status?.state ?? null; + if (state) { + next.set(session.sessionId, state); + } + const prevState = prev.get(session.sessionId); + // Detect busy → non-busy (idle, stopped, null/gone) for non-selected sessions + if (prevState === "busy" && state !== "busy" && session.sessionId !== selectedSessionId) { + newUnread.push(session.sessionId); + } + } + + prevSessionStatusRef.current = next; + + if (newUnread.length > 0) { + setUnreadSessionIds((prev) => { + const updated = new Set(prev); + for (const id of newUnread) updated.add(id); + return updated; + }); + } + }, [sessions, selectedSessionId]); + const handleSessionStatus = useCallback( (status: SessionStatus) => { applySessionStatus(status); + // Mark as unread when a non-selected session finishes (becomes non-busy) + if (status.state !== "busy" && status.sessionId !== selectedSessionId) { + setUnreadSessionIds((prev) => { + const next = new Set(prev); + next.add(status.sessionId); + return next; + }); + } + if (status.state !== "idle") { return; } @@ -291,7 +333,7 @@ function App() { ); refreshSession(status.sessionId); }, - [applySessionStatus, refreshSession], + [applySessionStatus, refreshSession, selectedSessionId], ); const handleCreateSession = useCallback( @@ -319,6 +361,13 @@ function App() { (sessionId: string) => { selectSession(sessionId); setIsMobileSidebarOpen(false); + // Clear unread status when user views the session + setUnreadSessionIds((prev) => { + if (!prev.has(sessionId)) return prev; + const next = new Set(prev); + next.delete(sessionId); + return next; + }); }, [selectSession], ); @@ -343,8 +392,10 @@ function App() { updatedAt: formatRelativeTime(session.lastUpdated), workDir: session.workDir, lastUpdated: session.lastUpdated, + statusState: session.status?.state ?? null, + isUnread: unreadSessionIds.has(session.sessionId), })), - [sessions], + [sessions, unreadSessionIds], ); // Transform archived Session[] to SessionSummary[] for sidebar @@ -356,6 +407,8 @@ function App() { updatedAt: formatRelativeTime(session.lastUpdated), workDir: session.workDir, lastUpdated: session.lastUpdated, + statusState: session.status?.state ?? null, + isUnread: false, })), [archivedSessions], ); diff --git a/web/src/features/sessions/sessions.tsx b/web/src/features/sessions/sessions.tsx index 6b55d7f33..1f582c37a 100644 --- a/web/src/features/sessions/sessions.tsx +++ b/web/src/features/sessions/sessions.tsx @@ -53,7 +53,8 @@ import { CollapsibleContent, } from "@/components/ui/collapsible"; import { hasPlatformModifier, isMacOS } from "@/hooks/utils"; -import { cn, } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import { motion } from "motion/react"; // Top-level regex constants for performance const NEWLINE_REGEX = /\r\n|\r|\n/; @@ -65,6 +66,10 @@ type SessionSummary = { updatedAt: string; workDir?: string | null; lastUpdated: Date; + /** Runtime session state: busy, idle, stopped, etc. */ + statusState?: string | null; + /** Whether this session has unread results */ + isUnread?: boolean; }; type ViewMode = "list" | "grouped"; @@ -87,6 +92,38 @@ function shortenPath(path: string, maxLen = 30): string { return ".../" + parts.slice(-2).join("/"); } +/** + * Small dot indicator for session status, rendered after the timestamp. + * - busy: green dot with outward pulse animation (matches the bottom status indicator) + * - unread: static blue dot + * - idle/default: no dot + */ +function SessionStatusDot({ statusState, isUnread }: { statusState?: string | null; isUnread?: boolean }) { + if (statusState === "busy") { + return ( + + + + + ); + } + if (isUnread) { + return ; + } + return null; +} + type SessionsSidebarProps = { sessions: SessionSummary[]; archivedSessions?: SessionSummary[]; @@ -936,8 +973,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ )} {!isEditing && ( - + {session.updatedAt} + )} @@ -1055,8 +1093,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ {normalizeTitle(session.title)} - + {session.updatedAt} + )}