Skip to content

Commit dacfe81

Browse files
author
xiejin
committed
feat(web): show session status indicators in sidebar
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.
1 parent 1865503 commit dacfe81

2 files changed

Lines changed: 97 additions & 5 deletions

File tree

web/src/App.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ function App() {
102102

103103
const [streamStatus, setStreamStatus] = useState<ChatStatus>("ready");
104104

105+
// Track sessions with unread results (completed a turn while user wasn't viewing)
106+
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(new Set());
107+
const prevSessionStatusRef = useRef<Map<string, string>>(new Map());
108+
105109
useEffect(() => {
106110
const token = consumeAuthTokenFromUrl();
107111
if (token) {
@@ -266,10 +270,48 @@ function App() {
266270
setStreamStatus(nextStatus);
267271
}, []);
268272

273+
// Detect busy→idle transitions from API refresh to mark sessions as unread
274+
useEffect(() => {
275+
const prev = prevSessionStatusRef.current;
276+
const next = new Map<string, string>();
277+
const newUnread: string[] = [];
278+
279+
for (const session of sessions) {
280+
const state = session.status?.state ?? null;
281+
if (state) {
282+
next.set(session.sessionId, state);
283+
}
284+
const prevState = prev.get(session.sessionId);
285+
// Detect busy → non-busy (idle, stopped, null/gone) for non-selected sessions
286+
if (prevState === "busy" && state !== "busy" && session.sessionId !== selectedSessionId) {
287+
newUnread.push(session.sessionId);
288+
}
289+
}
290+
291+
prevSessionStatusRef.current = next;
292+
293+
if (newUnread.length > 0) {
294+
setUnreadSessionIds((prev) => {
295+
const updated = new Set(prev);
296+
for (const id of newUnread) updated.add(id);
297+
return updated;
298+
});
299+
}
300+
}, [sessions, selectedSessionId]);
301+
269302
const handleSessionStatus = useCallback(
270303
(status: SessionStatus) => {
271304
applySessionStatus(status);
272305

306+
// Mark as unread when a non-selected session finishes (becomes non-busy)
307+
if (status.state !== "busy" && status.sessionId !== selectedSessionId) {
308+
setUnreadSessionIds((prev) => {
309+
const next = new Set(prev);
310+
next.add(status.sessionId);
311+
return next;
312+
});
313+
}
314+
273315
if (status.state !== "idle") {
274316
return;
275317
}
@@ -291,7 +333,7 @@ function App() {
291333
);
292334
refreshSession(status.sessionId);
293335
},
294-
[applySessionStatus, refreshSession],
336+
[applySessionStatus, refreshSession, selectedSessionId],
295337
);
296338

297339
const handleCreateSession = useCallback(
@@ -319,6 +361,13 @@ function App() {
319361
(sessionId: string) => {
320362
selectSession(sessionId);
321363
setIsMobileSidebarOpen(false);
364+
// Clear unread status when user views the session
365+
setUnreadSessionIds((prev) => {
366+
if (!prev.has(sessionId)) return prev;
367+
const next = new Set(prev);
368+
next.delete(sessionId);
369+
return next;
370+
});
322371
},
323372
[selectSession],
324373
);
@@ -343,8 +392,10 @@ function App() {
343392
updatedAt: formatRelativeTime(session.lastUpdated),
344393
workDir: session.workDir,
345394
lastUpdated: session.lastUpdated,
395+
statusState: session.status?.state ?? null,
396+
isUnread: unreadSessionIds.has(session.sessionId),
346397
})),
347-
[sessions],
398+
[sessions, unreadSessionIds],
348399
);
349400

350401
// Transform archived Session[] to SessionSummary[] for sidebar
@@ -356,6 +407,8 @@ function App() {
356407
updatedAt: formatRelativeTime(session.lastUpdated),
357408
workDir: session.workDir,
358409
lastUpdated: session.lastUpdated,
410+
statusState: session.status?.state ?? null,
411+
isUnread: false,
359412
})),
360413
[archivedSessions],
361414
);

web/src/features/sessions/sessions.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ import {
5353
CollapsibleContent,
5454
} from "@/components/ui/collapsible";
5555
import { hasPlatformModifier, isMacOS } from "@/hooks/utils";
56-
import { cn, } from "@/lib/utils";
56+
import { cn } from "@/lib/utils";
57+
import { motion } from "motion/react";
5758

5859
// Top-level regex constants for performance
5960
const NEWLINE_REGEX = /\r\n|\r|\n/;
@@ -65,6 +66,10 @@ type SessionSummary = {
6566
updatedAt: string;
6667
workDir?: string | null;
6768
lastUpdated: Date;
69+
/** Runtime session state: busy, idle, stopped, etc. */
70+
statusState?: string | null;
71+
/** Whether this session has unread results */
72+
isUnread?: boolean;
6873
};
6974

7075
type ViewMode = "list" | "grouped";
@@ -87,6 +92,38 @@ function shortenPath(path: string, maxLen = 30): string {
8792
return ".../" + parts.slice(-2).join("/");
8893
}
8994

95+
/**
96+
* Small dot indicator for session status, rendered after the timestamp.
97+
* - busy: green dot with outward pulse animation (matches the bottom status indicator)
98+
* - unread: static blue dot
99+
* - idle/default: no dot
100+
*/
101+
function SessionStatusDot({ statusState, isUnread }: { statusState?: string | null; isUnread?: boolean }) {
102+
if (statusState === "busy") {
103+
return (
104+
<span className="relative inline-flex size-3 shrink-0 items-center justify-center" aria-label="Running">
105+
<motion.span
106+
className="absolute size-2.5 rounded-full bg-green-500/50"
107+
animate={{
108+
scale: [1, 1.8, 1],
109+
opacity: [0.6, 0, 0.6],
110+
}}
111+
transition={{
112+
duration: 1.5,
113+
repeat: Number.POSITIVE_INFINITY,
114+
ease: "easeInOut",
115+
}}
116+
/>
117+
<span className="relative inline-block size-1.5 rounded-full bg-green-500" />
118+
</span>
119+
);
120+
}
121+
if (isUnread) {
122+
return <span className="inline-block size-1.5 shrink-0 rounded-full bg-blue-500" aria-label="Unread" />;
123+
}
124+
return null;
125+
}
126+
90127
type SessionsSidebarProps = {
91128
sessions: SessionSummary[];
92129
archivedSessions?: SessionSummary[];
@@ -936,8 +973,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
936973
</Tooltip>
937974
)}
938975
{!isEditing && (
939-
<span className="text-[10px] text-muted-foreground mt-1 block">
976+
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground mt-1">
940977
{session.updatedAt}
978+
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
941979
</span>
942980
)}
943981
</button>
@@ -1055,8 +1093,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
10551093
{normalizeTitle(session.title)}
10561094
</TooltipContent>
10571095
</Tooltip>
1058-
<span className="text-[10px] text-muted-foreground shrink-0">
1096+
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground shrink-0">
10591097
{session.updatedAt}
1098+
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
10601099
</span>
10611100
</div>
10621101
)}

0 commit comments

Comments
 (0)