diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index b9562fb0..2440216e 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -922,6 +922,7 @@ function SessionTree({ <> void; showDate?: boolean; + /** Feed-only: light up an "unread" marker when updated since last opened. */ + showUnread?: boolean; /** Nesting: depth (0 = top level) indents the row; a parent shows a chevron in place of its icon that toggles its children. */ depth?: number; @@ -997,6 +1000,12 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl const inputRef = useRef(null); // Unsent draft for this chat (hidden on the active one — its text is in the box). const hasDraft = useChatStore(s => !!(s.drafts[session.id] || '').trim()); + // Unread = updated since you last opened it (client-only, see readStorage). + // Never for the open session or a running one; feed-scoped via showUnread. + const lastSeen = useChatStore(s => s.reads[session.id]); + const readsBaseline = useChatStore(s => s.readsBaseline); + const isUnread = showUnread && !isActive && !isRunning + && parseTimestamp(session.updated_at).getTime() > Math.max(lastSeen ?? 0, readsBaseline); // Close menu on outside click useEffect(() => { @@ -1090,7 +1099,7 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl )}
-
{cleanTitle(session)}
+
{cleanTitle(session)}
{/* Collapsed parent: badge the hidden direct-child count (mirrors GroupHeader). */} @@ -1103,6 +1112,13 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl )} + {/* Unread marker: updated since you last opened it (client-only). */} + {isUnread && ( + + + + )} + {/* Unsent draft marker */} {hasDraft && !isActive && ( diff --git a/web/src/stores/authStore.ts b/web/src/stores/authStore.ts index 8bb31880..3d8aef6e 100644 --- a/web/src/stores/authStore.ts +++ b/web/src/stores/authStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { api, setToken, clearToken, getToken, setUnauthorizedHandler } from '../api/client'; import { clearAllDrafts } from './helpers/draftStorage'; +import { clearAllReads } from './helpers/readStorage'; interface AuthState { authenticated: boolean; @@ -57,6 +58,7 @@ export const useAuthStore = create((set) => ({ // browser. Only on a *deliberate* logout — an expired session must never // take your unsent work with it. clearAllDrafts(); + clearAllReads(); sessionEstablished = false; // back to a cold start: next 401 is not an "expiry" set({ authenticated: false, sessionExpired: false }); }, diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 5d77b3aa..ecab9085 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -10,6 +10,7 @@ import { randomUUID } from '../utils/uuid'; import { cancelAutoClose, clearAllAutoCloseTimers, MAX_COMPLETED_TABS } from './helpers/blockHelpers'; import { extractTodosFromMessages, extractCCTasksFromMessages } from './helpers/bufferReplay'; import { loadDrafts, persistDraft, removeDraft, pruneDrafts } from './helpers/draftStorage'; +import { loadReads, persistRead, removeRead, loadBaseline } from './helpers/readStorage'; import { loadVirtualSession, persistVirtualSession, clearVirtualSession } from './helpers/virtualSessionStorage'; // Handlers import { handleThinking, handleToken, handleToolUse, handleToolResult, handleToolOutput, handleDone, handleStopped, handleError, handleWakeup, handleAutoTurn, handleModelChanged } from './handlers/streamingHandlers'; @@ -107,6 +108,10 @@ interface ChatState { virtualSession: Session | null; // Per-session unsent input text, keyed by session id (incl. the virtual one). drafts: Record; + // Per-session "last seen" moment (ms) + a one-time baseline. A session is + // "unread" when its updated_at is newer than max(reads[id], readsBaseline). + reads: Record; + readsBaseline: number; messages: ChatMessage[]; // Streaming state — blocks built incrementally streamingBlocks: MessageBlock[]; @@ -227,6 +232,7 @@ interface ChatState { ensureRealSession: (running?: boolean) => Promise; discardVirtualSession: () => void; setDraft: (sessionId: string, text: string) => void; + markSeen: (sessionId: string) => void; deleteSession: (id: string) => Promise; archiveSession: (id: string) => Promise; unarchiveSession: (id: string) => Promise; @@ -329,6 +335,10 @@ export const useChatStore = create((set, get) => ({ virtualSession: restoreVirtualSession(), // Rehydrated from localStorage so unsent composer text survives a reload. drafts: loadDrafts(), + // Read/unread tracking (client-only): per-session last-seen stamps + the + // first-run baseline that keeps pre-existing sessions from all showing unread. + reads: loadReads(), + readsBaseline: loadBaseline(), messages: [], streamingBlocks: [], isStreaming: false, @@ -580,6 +590,10 @@ export const useChatStore = create((set, get) => ({ }, switchSession: async (id: string) => { + // Opening a session marks whatever you're leaving as read — you've now seen + // its latest content (the incoming session is marked on the real path below). + const leaving = get().activeSession; + if (leaving && leaving !== id) get().markSeen(leaving); // Leaving an untouched (empty-draft) virtual chat discards it, so the // sidebar never accumulates empty "New chat" entries. A filled // review-loop form counts as touched — don't silently drop it. @@ -614,6 +628,8 @@ export const useChatStore = create((set, get) => ({ set({ loading: false }); return; } + // Real session opened → mark it read (R1: auto-clear its unread marker). + get().markSeen(id); ws.switchSession(id); // Note: opening a chat deliberately does NOT touch updated_at (locally or // server-side) — updated_at means "last message activity", so browsing @@ -779,10 +795,18 @@ export const useChatStore = create((set, get) => ({ return { drafts: { ...s.drafts, [sessionId]: text } }; }), + markSeen: (sessionId: string) => { + if (!sessionId) return; + const now = Date.now(); + persistRead(sessionId, now); + set((s) => ({ reads: { ...s.reads, [sessionId]: now } })); + }, + deleteSession: async (id: string) => { try { await api.deleteSession(id); removeDraft(id); + removeRead(id); set(s => { const drafts = { ...s.drafts }; delete drafts[id]; return { drafts }; }); await get().loadSessions(); if (get().activeSession === id) { diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index 0e957eeb..4e0cb4ad 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -219,7 +219,7 @@ export function handleSessionArchived( export function handleSessionRunning( msg: Extract, - _get: Get, + get: Get, set: Set, ): void { // Global broadcast: a session started or stopped running @@ -275,6 +275,17 @@ export function handleSessionRunning( } return updates; }); + + // A backgrounded feed session that just STOPPED may have produced new content; + // its updated_at only refreshes via loadSessions() (session_running carries + // just is_running), so pull a fresh list to keep the unread marker accurate. + // Scoped: skip the active session (its own stream reloads) and cron/system + // churn (session ids that never enter the `sessions` feed). + if (!msg.is_running + && msg.session_id !== get().activeSession + && get().sessions.some(sess => sess.id === msg.session_id)) { + get().loadSessions(); + } } export function handleSessionAwaitingInput( diff --git a/web/src/stores/helpers/readStorage.ts b/web/src/stores/helpers/readStorage.ts new file mode 100644 index 00000000..d90ca720 --- /dev/null +++ b/web/src/stores/helpers/readStorage.ts @@ -0,0 +1,80 @@ +// Per-session read/unread tracking (client-only). +// +// "Unread" is derived, not stored: a session is unread when its server +// `updated_at` is newer than the last time you opened/viewed it. We persist +// only that per-session "last seen" moment (ms since epoch) plus a one-time +// baseline — both in localStorage, no server state. Read-state is inherently +// per-viewer, and the one server datum it needs (updated_at) already ships in +// the session list payload. +// +// One key per session (`nerve_read_`) mirrors draftStorage: two tabs can't +// clobber each other and per-session cleanup is a single removeItem. Every +// access is quota-/disabled-safe — an unread marker is a convenience, never a +// blocker. + +const PREFIX = 'nerve_read_'; +const BASELINE_KEY = 'nerve_reads_baseline'; + +const keyFor = (sessionId: string) => `${PREFIX}${sessionId}`; + +/** Collect the keys of all persisted read stamps (safe if storage is off). */ +function readKeys(): string[] { + const keys: string[] = []; + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(PREFIX)) keys.push(k); + } + } catch { /* storage unavailable */ } + return keys; +} + +/** Hydrate every persisted "last seen" stamp into a { sessionId: ms } map. */ +export function loadReads(): Record { + const out: Record = {}; + for (const k of readKeys()) { + try { + const raw = localStorage.getItem(k); + const ts = raw ? parseInt(raw, 10) : NaN; + if (Number.isFinite(ts)) out[k.slice(PREFIX.length)] = ts; + } catch { /* ignore a single unreadable key */ } + } + return out; +} + +/** Write-through one session's "last seen" moment (ms since epoch). */ +export function persistRead(sessionId: string, ts: number): void { + if (!sessionId) return; + try { localStorage.setItem(keyFor(sessionId), String(ts)); } + catch { /* quota / disabled — the in-memory stamp still applies this session */ } +} + +/** Drop one session's persisted stamp (session deleted). */ +export function removeRead(sessionId: string): void { + if (!sessionId) return; + try { localStorage.removeItem(keyFor(sessionId)); } catch { /* ignore */ } +} + +/** + * The "everything at or before this is already read" cutoff, set once on the + * first run so a fresh browser doesn't light up every pre-existing session as + * unread. Persisted so it survives reloads; re-created if storage was cleared. + */ +export function loadBaseline(): number { + try { + const raw = localStorage.getItem(BASELINE_KEY); + const ts = raw ? parseInt(raw, 10) : NaN; + if (Number.isFinite(ts)) return ts; + } catch { /* fall through to (re)initialise */ } + const now = Date.now(); + try { localStorage.setItem(BASELINE_KEY, String(now)); } catch { /* best-effort */ } + return now; +} + +/** Wipe all read stamps + baseline — the shared-browser control, run on logout. */ +export function clearAllReads(): void { + for (const k of readKeys()) { + try { localStorage.removeItem(k); } catch { /* ignore */ } + } + try { localStorage.removeItem(BASELINE_KEY); } catch { /* ignore */ } +}