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
20 changes: 18 additions & 2 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@ function SessionTree({
<>
<SessionItem
session={session}
showUnread
isActive={session.id === activeSession}
isRunning={session.id === activeSession ? activeIsRunning : !!session.is_running}
depth={depth}
Expand Down Expand Up @@ -962,7 +963,7 @@ function SessionTree({
}


function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, onSelect, showDate,
function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, onSelect, showDate, showUnread = false,
depth = 0, hasChildren = false, childCount = 0, expanded = false, onToggleExpand, onRemoveParent, draggable = false, dnd }: {
session: Session;
isActive: boolean;
Expand All @@ -977,6 +978,8 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl
/** Fired when the row itself is opened (not its menu) — drawer mode uses it to close. */
onSelect?: () => 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;
Expand All @@ -997,6 +1000,12 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl
const inputRef = useRef<HTMLInputElement>(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(() => {
Expand Down Expand Up @@ -1090,7 +1099,7 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl
<MessageSquare size={13} className="shrink-0 opacity-50" />
)}
<div className="flex-1 min-w-0">
<div className="truncate text-[13px]">{cleanTitle(session)}</div>
<div className={`truncate text-[13px]${isUnread ? ' font-semibold text-text' : ''}`}>{cleanTitle(session)}</div>
</div>

{/* Collapsed parent: badge the hidden direct-child count (mirrors GroupHeader). */}
Expand All @@ -1103,6 +1112,13 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl
</span>
)}

{/* Unread marker: updated since you last opened it (client-only). */}
{isUnread && (
<span title="Unread — updated since you last opened it" className="shrink-0 flex items-center">
<span className="h-2 w-2 rounded-full bg-accent" />
</span>
Comment on lines +1117 to +1119
)}

{/* Unsent draft marker */}
{hasDraft && !isActive && (
<span title="Unsent draft" className="shrink-0 flex items-center">
Expand Down
2 changes: 2 additions & 0 deletions web/src/stores/authStore.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -57,6 +58,7 @@ export const useAuthStore = create<AuthState>((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 });
},
Expand Down
24 changes: 24 additions & 0 deletions web/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -107,6 +108,10 @@ interface ChatState {
virtualSession: Session | null;
// Per-session unsent input text, keyed by session id (incl. the virtual one).
drafts: Record<string, string>;
// 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<string, number>;
readsBaseline: number;
messages: ChatMessage[];
// Streaming state — blocks built incrementally
streamingBlocks: MessageBlock[];
Expand Down Expand Up @@ -227,6 +232,7 @@ interface ChatState {
ensureRealSession: (running?: boolean) => Promise<string>;
discardVirtualSession: () => void;
setDraft: (sessionId: string, text: string) => void;
markSeen: (sessionId: string) => void;
deleteSession: (id: string) => Promise<void>;
archiveSession: (id: string) => Promise<void>;
unarchiveSession: (id: string) => Promise<void>;
Expand Down Expand Up @@ -329,6 +335,10 @@ export const useChatStore = create<ChatState>((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,
Expand Down Expand Up @@ -580,6 +590,10 @@ export const useChatStore = create<ChatState>((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.
Expand Down Expand Up @@ -614,6 +628,8 @@ export const useChatStore = create<ChatState>((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
Expand Down Expand Up @@ -779,10 +795,18 @@ export const useChatStore = create<ChatState>((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) {
Expand Down
13 changes: 12 additions & 1 deletion web/src/stores/handlers/sessionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function handleSessionArchived(

export function handleSessionRunning(
msg: Extract<WSMessage, { type: 'session_running' }>,
_get: Get,
get: Get,
set: Set,
): void {
// Global broadcast: a session started or stopped running
Expand Down Expand Up @@ -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();
Comment on lines +284 to +287
}
}

export function handleSessionAwaitingInput(
Expand Down
80 changes: 80 additions & 0 deletions web/src/stores/helpers/readStorage.ts
Original file line number Diff line number Diff line change
@@ -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_<id>`) 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<string, number> {
const out: Record<string, number> = {};
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 */ }
}
Loading