Skip to content
Open
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
120 changes: 107 additions & 13 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ function formatShortDate(dateStr: string): string {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}

// Which session groups (Running / Starred / date buckets) the user has
// collapsed, persisted across reloads. Keyed by the group's visible label,
// mirroring the quota-safe write-through pattern in helpers/draftStorage.ts —
// if localStorage is full or disabled the collapse state stays in memory only.
const COLLAPSED_GROUPS_KEY = 'nerve_sidebar_collapsed_groups';

function loadCollapsedGroups(): Set<string> {
try {
const raw = localStorage.getItem(COLLAPSED_GROUPS_KEY);
const arr = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(arr) ? arr.filter((x: unknown): x is string => typeof x === 'string') : []);
} catch {
return new Set();
}
}

function saveCollapsedGroups(groups: Set<string>): void {
try {
localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify([...groups]));
} catch { /* quota exceeded / disabled — keep the in-memory state only */ }
}

export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed }: {
sessions: Session[];
activeSession: string;
Expand All @@ -41,6 +63,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
collapsed?: boolean;
}) {
const [systemExpanded, setSystemExpanded] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(loadCollapsedGroups);
const [localQuery, setLocalQuery] = useState('');
const [searchHovered, setSearchHovered] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
Expand Down Expand Up @@ -208,6 +231,40 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,

const groupedConversations = useMemo(() => groupByDate(restConversations), [restConversations]);

// Collapse/expand a session group (Running / Starred / a date bucket),
// persisting the new set so it survives a reload.
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
saveCollapsedGroups(next);
return next;
});
}, []);

// Never leave the active session hidden inside a collapsed group: when the
// active session changes, expand whichever group holds it — once, so a later
// manual collapse of that same group still sticks.
const autoExpandedForRef = useRef<string | null>(null);
useEffect(() => {
if (!activeSession || autoExpandedForRef.current === activeSession) return;
let label: string | null = null;
if (pinnedRunning.some(s => s.id === activeSession)) label = 'Running';
else if (pinnedStarred.some(s => s.id === activeSession)) label = 'Starred';
else label = groupedConversations.find(g => g.items.some(s => s.id === activeSession))?.group ?? null;
if (!label) return; // not located yet (sessions still loading) — retry on the next update
const found = label;
autoExpandedForRef.current = activeSession;
setCollapsedGroups(prev => {
if (!prev.has(found)) return prev;
const next = new Set(prev);
next.delete(found);
saveCollapsedGroups(next);
return next;
});
}, [activeSession, pinnedRunning, pinnedStarred, groupedConversations]);

// Count running system sessions for the badge
const runningSystemCount = useMemo(
() => systemSessions.filter(s => s.is_running).length,
Expand Down Expand Up @@ -293,7 +350,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
</div>
</div>

<div className="flex-1 overflow-y-auto">
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Search results mode */}
{isSearching ? (
<div>
Expand Down Expand Up @@ -361,10 +418,14 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
{/* Pinned running sessions */}
{pinnedRunning.length > 0 && (
<div>
<div className="px-3 pt-2 pb-0.5">
<span className="text-[10px] text-emerald-600/70 font-medium">Running</span>
</div>
{pinnedRunning.map((s) => (
<GroupHeader
label="Running"
count={pinnedRunning.length}
collapsed={collapsedGroups.has('Running')}
tone="text-emerald-600/70"
onToggle={() => toggleGroup('Running')}
/>
{!collapsedGroups.has('Running') && pinnedRunning.map((s) => (
<SessionItem
key={s.id}
session={s}
Expand All @@ -382,10 +443,14 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
stable while browsing, since opening a chat doesn't bump it) */}
{pinnedStarred.length > 0 && (
<div>
<div className="px-3 pt-2 pb-0.5">
<span className="text-[10px] text-yellow-600/70 font-medium">Starred</span>
</div>
{pinnedStarred.map((s) => (
<GroupHeader
label="Starred"
count={pinnedStarred.length}
collapsed={collapsedGroups.has('Starred')}
tone="text-yellow-600/70"
onToggle={() => toggleGroup('Starred')}
/>
{!collapsedGroups.has('Starred') && pinnedStarred.map((s) => (
<SessionItem
key={s.id}
session={s}
Expand All @@ -406,10 +471,13 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,

{groupedConversations.map(({ group, items }) => (
<div key={group}>
<div className="px-3 pt-2.5 pb-0.5">
<span className="text-[10px] text-text-faint font-medium">{group}</span>
</div>
{items.map((s) => (
<GroupHeader
label={group}
count={items.length}
collapsed={collapsedGroups.has(group)}
onToggle={() => toggleGroup(group)}
/>
{!collapsedGroups.has(group) && items.map((s) => (
<SessionItem
key={s.id}
session={s}
Expand Down Expand Up @@ -480,6 +548,32 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
}


/** Collapsable session-group header: chevron + label, with a hidden-count hint when collapsed. */
function GroupHeader({ label, count, collapsed, tone, onToggle }: {
label: string;
count: number;
collapsed: boolean;
tone?: string;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="flex items-center gap-1 px-3 pt-2 pb-0.5 w-full text-left cursor-pointer hover:bg-surface-raised/60 transition-colors"
>
{collapsed
? <ChevronRight size={10} className="shrink-0 text-text-faint" />
: <ChevronDown size={10} className="shrink-0 text-text-faint" />
}
<span className={`text-[10px] font-medium ${tone ?? 'text-text-faint'}`}>{label}</span>
{collapsed && count > 0 && (
<span className="text-[10px] text-text-faint/60 tabular-nums">{count}</span>
)}
</button>
);
}


/** Sidebar icon tint for review-loop observer sessions, by loop status. */
function reviewLoopIconTone(status: string): string {
switch (status) {
Expand Down
Loading