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
33 changes: 33 additions & 0 deletions src/client/__tests__/sessionState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,37 @@ describe('useSessionStore', () => {

expect(useSessionStore.getState().sessions).toEqual(sessions)
})

test('markSessionUnread adds session id to unreadSessionIds', () => {
useSessionStore.setState({ unreadSessionIds: new Set() })
useSessionStore.getState().markSessionUnread('session-1')
expect(useSessionStore.getState().unreadSessionIds.has('session-1')).toBe(true)
})

test('markSessionRead removes session id from unreadSessionIds', () => {
useSessionStore.setState({ unreadSessionIds: new Set(['session-1', 'session-2']) })
useSessionStore.getState().markSessionRead('session-1')
const unread = useSessionStore.getState().unreadSessionIds
expect(unread.has('session-1')).toBe(false)
expect(unread.has('session-2')).toBe(true)
})

test('markSessionRead is a no-op for unknown session ids', () => {
const original = new Set(['session-1'])
useSessionStore.setState({ unreadSessionIds: original })
useSessionStore.getState().markSessionRead('unknown')
// Should be the same Set reference (no unnecessary update)
expect(useSessionStore.getState().unreadSessionIds).toBe(original)
})

test('setSessions cleans up removed sessions from unreadSessionIds', () => {
useSessionStore.setState({
unreadSessionIds: new Set(['session-1', 'session-2']),
})
// Only session-1 survives in the new session list
useSessionStore.getState().setSessions([makeSession({ id: 'session-1' })])
const unread = useSessionStore.getState().unreadSessionIds
expect(unread.has('session-1')).toBe(true)
expect(unread.has('session-2')).toBe(false)
})
})
16 changes: 14 additions & 2 deletions src/client/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,10 @@ export default function SessionList({
showLastUserMessage={showLastUserMessage}
showHostInfo={showHostInfo}
dropIndicator={showDropIndicator}
onSelect={() => onSelect(session.id)}
onSelect={() => {
useSessionStore.getState().markSessionRead(session.id)
onSelect(session.id)
}}
onStartEdit={canControl ? () => setEditingSessionId(session.id) : undefined}
onCancelEdit={() => setEditingSessionId(null)}
onRename={(newName) => handleRename(session.id, newName)}
Expand Down Expand Up @@ -855,6 +858,8 @@ function SessionRow({
// Track previous status for transition animation
const prevStatusRef = useRef<Session['status']>(session.status)
const [isPulsingComplete, setIsPulsingComplete] = useState(false)
const markSessionUnread = useSessionStore((state) => state.markSessionUnread)
const isUnread = useSessionStore((state) => state.unreadSessionIds.has(session.id))

useEffect(() => {
const prevStatus = prevStatusRef.current
Expand All @@ -863,11 +868,15 @@ function SessionRow({
// Detect transition from working → waiting (not permission, which needs immediate attention)
if (prevStatus === 'working' && currentStatus === 'waiting') {
setIsPulsingComplete(true)
// Mark as unread if the user isn't currently viewing this session
if (!isSelected) {
markSessionUnread(session.id)
}
// Don't update ref yet - will update when animation ends
} else {
prevStatusRef.current = currentStatus
}
}, [session.status])
}, [session.status, isSelected, session.id, markSessionUnread])

const handlePulseAnimationEnd = () => {
setIsPulsingComplete(false)
Expand Down Expand Up @@ -1012,6 +1021,9 @@ function SessionRow({
{sessionIdPrefix}
</span>
)}
{isUnread && !isSelected && (
<span className="unread-dot" aria-label="Unread" />
)}
{needsInput ? (
<span
className={`ml-1 flex shrink-0 items-center justify-center rounded-full px-1.5 py-0.5 ${statusPillClass[session.status]} pulse-approval`}
Expand Down
64 changes: 55 additions & 9 deletions src/client/stores/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ interface SessionState {
// Sessions being animated out - keyed by session ID, value is the session data
exitingSessions: Map<string, Session>
selectedSessionId: string | null
// Sessions with new completed output the user hasn't viewed yet
unreadSessionIds: Set<string>
hasLoaded: boolean
connectionStatus: ConnectionStatus
connectionError: string | null
Expand All @@ -72,6 +74,10 @@ interface SessionState {
setRemoteAllowAttach: (value: boolean) => void
hostLabel: string | null
setHostLabel: (value: string | null) => void
// Mark a session as having new unread output
markSessionUnread: (sessionId: string) => void
// Clear unread state (user has viewed the session)
markSessionRead: (sessionId: string) => void
// Mark a session as exiting (preserves data for exit animation)
markSessionExiting: (sessionId: string) => void
// Clear a session from exiting state (after animation completes)
Expand All @@ -86,6 +92,7 @@ export const useSessionStore = create<SessionState>()(
hostStatuses: [],
exitingSessions: new Map(),
selectedSessionId: null,
unreadSessionIds: new Set(),
hasLoaded: false,
connectionStatus: 'connecting',
connectionError: null,
Expand All @@ -112,6 +119,17 @@ export const useSessionStore = create<SessionState>()(
(s) => !newSessionIds.has(s.id) && !exitingSessions.has(s.id)
)

// Clean up unread IDs for removed sessions
const unreadSessionIds = state.unreadSessionIds
let nextUnread = unreadSessionIds
if (unreadSessionIds.size > 0) {
const staleIds = [...unreadSessionIds].filter((id) => !newSessionIds.has(id))
if (staleIds.length > 0) {
nextUnread = new Set(unreadSessionIds)
for (const id of staleIds) nextUnread.delete(id)
}
}

let newSelectedId: string | null = selected
if (
selected !== null &&
Expand All @@ -127,24 +145,25 @@ export const useSessionStore = create<SessionState>()(
newSelectedId = sorted[0]?.id ?? null
}

const baseUpdate = {
sessions,
hasLoaded: true,
selectedSessionId: newSelectedId,
...(nextUnread !== unreadSessionIds ? { unreadSessionIds: nextUnread } : {}),
}

// Only update exitingSessions if there are newly removed sessions
if (removedSessions.length > 0) {
const nextExitingSessions = new Map(exitingSessions)
for (const session of removedSessions) {
nextExitingSessions.set(session.id, session)
}
set({
sessions,
hasLoaded: true,
selectedSessionId: newSelectedId,
...baseUpdate,
exitingSessions: nextExitingSessions,
})
} else {
set({
sessions,
hasLoaded: true,
selectedSessionId: newSelectedId,
})
set(baseUpdate)
}
},
setAgentSessions: (active, inactive) =>
Expand All @@ -164,6 +183,18 @@ export const useSessionStore = create<SessionState>()(
setRemoteAllowControl: (value) => set({ remoteAllowControl: value }),
setRemoteAllowAttach: (value) => set({ remoteAllowAttach: value }),
setHostLabel: (value) => set({ hostLabel: value }),
markSessionUnread: (sessionId) => {
const next = new Set(get().unreadSessionIds)
next.add(sessionId)
set({ unreadSessionIds: next })
},
markSessionRead: (sessionId) => {
const current = get().unreadSessionIds
if (!current.has(sessionId)) return
const next = new Set(current)
next.delete(sessionId)
set({ unreadSessionIds: next })
},
markSessionExiting: (sessionId) => {
const session = get().sessions.find((s) => s.id === sessionId)
if (session) {
Expand All @@ -181,7 +212,22 @@ export const useSessionStore = create<SessionState>()(
{
name: SESSION_PERSIST_KEY,
storage: createJSONStorage(() => tabStorage),
partialize: (state) => ({ selectedSessionId: state.selectedSessionId }),
partialize: (state) => ({
selectedSessionId: state.selectedSessionId,
unreadSessionIds: [...state.unreadSessionIds],
}),
merge: (persisted, current) => {
const data = persisted as Record<string, unknown> | undefined
return {
...current,
...(data ?? {}),
unreadSessionIds: new Set(
Array.isArray(data?.unreadSessionIds)
? (data.unreadSessionIds as string[])
: []
),
}
},
}
)
)
19 changes: 19 additions & 0 deletions src/client/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,21 @@ body {
animation: pulse-complete 5s ease-out;
}

/* Unread indicator dot for newly completed sessions */
@keyframes unread-fade-in {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}

.unread-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: unread-fade-in 0.2s ease-out;
flex-shrink: 0;
}

/* Terminal styles */
.xterm {
font-family: "JetBrains Mono Variable", "JetBrains Mono", "SF Mono", "Fira Code", ui-monospace, monospace;
Expand Down Expand Up @@ -559,4 +574,8 @@ body {
.pulse-complete {
animation: none;
}

.unread-dot {
animation: none;
}
}