From 5f179e3c3fcfc5c2ec59b88b674c37ecf685bb50 Mon Sep 17 00:00:00 2001 From: Master-cai Date: Wed, 4 Mar 2026 23:47:17 +0800 Subject: [PATCH] feat(client): add unread indicator for completed sessions --- src/client/__tests__/sessionState.test.ts | 33 ++++++++++++ src/client/components/SessionList.tsx | 16 +++++- src/client/stores/sessionStore.ts | 64 +++++++++++++++++++---- src/client/styles/index.css | 19 +++++++ 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/src/client/__tests__/sessionState.test.ts b/src/client/__tests__/sessionState.test.ts index ac323839..0d3e0c03 100644 --- a/src/client/__tests__/sessionState.test.ts +++ b/src/client/__tests__/sessionState.test.ts @@ -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) + }) }) diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx index 9080a5db..4e9703f7 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -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)} @@ -855,6 +858,8 @@ function SessionRow({ // Track previous status for transition animation const prevStatusRef = useRef(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 @@ -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) @@ -1012,6 +1021,9 @@ function SessionRow({ {sessionIdPrefix} )} + {isUnread && !isSelected && ( + + )} {needsInput ? ( selectedSessionId: string | null + // Sessions with new completed output the user hasn't viewed yet + unreadSessionIds: Set hasLoaded: boolean connectionStatus: ConnectionStatus connectionError: string | null @@ -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) @@ -86,6 +92,7 @@ export const useSessionStore = create()( hostStatuses: [], exitingSessions: new Map(), selectedSessionId: null, + unreadSessionIds: new Set(), hasLoaded: false, connectionStatus: 'connecting', connectionError: null, @@ -112,6 +119,17 @@ export const useSessionStore = create()( (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 && @@ -127,6 +145,13 @@ export const useSessionStore = create()( 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) @@ -134,17 +159,11 @@ export const useSessionStore = create()( 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) => @@ -164,6 +183,18 @@ export const useSessionStore = create()( 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) { @@ -181,7 +212,22 @@ export const useSessionStore = create()( { 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 | undefined + return { + ...current, + ...(data ?? {}), + unreadSessionIds: new Set( + Array.isArray(data?.unreadSessionIds) + ? (data.unreadSessionIds as string[]) + : [] + ), + } + }, } ) ) diff --git a/src/client/styles/index.css b/src/client/styles/index.css index ea8a213f..ad3ee7eb 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -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; @@ -559,4 +574,8 @@ body { .pulse-complete { animation: none; } + + .unread-dot { + animation: none; + } }