Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ function App() {

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

// Track sessions with unread results (completed a turn while user wasn't viewing)
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(new Set());
const prevSessionStatusRef = useRef<Map<string, string>>(new Map());

useEffect(() => {
const token = consumeAuthTokenFromUrl();
if (token) {
Expand Down Expand Up @@ -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<string, string>();
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;
});
}
Comment on lines +306 to +313
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 handleSessionStatus marks unread on any non-busy status instead of only busy→non-busy transitions

The unread marking logic in handleSessionStatus at line 307 marks a session as unread whenever status.state !== "busy" for a non-selected session, without checking whether the previous state was "busy". This differs from the useEffect at web/src/App.tsx:284-287 which correctly checks prevState === "busy" && state !== "busy" to detect transitions only.

In the current architecture this code is effectively dead — the WebSocket connects per-session to the selected session, so status.sessionId !== selectedSessionId is always false. However, if the backend or architecture ever changes to deliver cross-session status events through a single WebSocket, this would cause false-positive unread badges: any non-busy status (e.g., an idle status with reason: "config_update", or a stopped session that was never busy) would incorrectly mark the session as unread, and the unread dot would reappear even after the user has cleared it by clicking the session.

Prompt for agents
In web/src/App.tsx, the unread marking inside handleSessionStatus (lines 306-313) should track the previous state to only mark as unread on busy→non-busy transitions, matching the useEffect logic at lines 274-300. Since handleSessionStatus is called per-event (not on batch session updates), you'd need to consult prevSessionStatusRef to check the previous state:

1. Read the previous state from prevSessionStatusRef.current.get(status.sessionId)
2. Only add to unreadSessionIds if the previous state was "busy" AND the new state is not "busy"
3. Update prevSessionStatusRef.current with the new state

Alternatively, since this code path currently never fires (WebSocket is per-session, so status.sessionId always equals selectedSessionId), consider removing this block entirely and relying solely on the useEffect at lines 274-300 for unread detection.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if (status.state !== "idle") {
return;
}
Expand All @@ -291,7 +333,7 @@ function App() {
);
refreshSession(status.sessionId);
},
[applySessionStatus, refreshSession],
[applySessionStatus, refreshSession, selectedSessionId],
);

const handleCreateSession = useCallback(
Expand Down Expand Up @@ -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;
Comment on lines +365 to +369
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear unread when selection changes from any path

Unread state is currently cleared only in the sidebar click handler, so programmatic selection paths (for example URL restore in App, or auto-selection after deleting the current session in useSessions.deleteSession) bypass this logic. When that happens, a session can stay marked unread even while it is the active session, leaving a stale blue indicator. Clearing unread based on selectedSessionId changes (instead of only click events) would keep the indicator consistent.

Useful? React with 👍 / 👎.

});
},
[selectSession],
);
Expand All @@ -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
Expand All @@ -356,6 +407,8 @@ function App() {
updatedAt: formatRelativeTime(session.lastUpdated),
workDir: session.workDir,
lastUpdated: session.lastUpdated,
statusState: session.status?.state ?? null,
isUnread: false,
})),
[archivedSessions],
);
Expand Down
45 changes: 42 additions & 3 deletions web/src/features/sessions/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand All @@ -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";
Expand All @@ -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 (
<span className="relative inline-flex size-3 shrink-0 items-center justify-center" aria-label="Running">
<motion.span
className="absolute size-2.5 rounded-full bg-green-500/50"
animate={{
scale: [1, 1.8, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<span className="relative inline-block size-1.5 rounded-full bg-green-500" />
</span>
);
}
if (isUnread) {
return <span className="inline-block size-1.5 shrink-0 rounded-full bg-blue-500" aria-label="Unread" />;
}
return null;
}

type SessionsSidebarProps = {
sessions: SessionSummary[];
archivedSessions?: SessionSummary[];
Expand Down Expand Up @@ -936,8 +973,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
</Tooltip>
)}
{!isEditing && (
<span className="text-[10px] text-muted-foreground mt-1 block">
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground mt-1">
{session.updatedAt}
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
</span>
)}
</button>
Expand Down Expand Up @@ -1055,8 +1093,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({
{normalizeTitle(session.title)}
</TooltipContent>
</Tooltip>
<span className="text-[10px] text-muted-foreground shrink-0">
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground shrink-0">
{session.updatedAt}
<SessionStatusDot statusState={session.statusState} isUnread={session.isUnread} />
</span>
</div>
)}
Expand Down
Loading