import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Folder,
MessageSquarePlus,
PanelLeftClose,
Pencil,
Plus,
Settings as SettingsIcon,
Trash2,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { AppTab, Project, ProjectSession } from '../../types/app';
import { cn } from '../../lib/utils.js';
import { isImeEnterEvent } from '../../utils/ime';
import {
projectDisplayName,
sessionDisplayTitle,
setProjectCustomName,
setSessionCustomTitle,
useCustomNamesVersion,
} from '../../lib/customNames';
import pilotdeckLogoDark from '../../assets/pilotdeck-wordmark-dark.png';
import pilotdeckLogoLight from '../../assets/pilotdeck-wordmark-light.png';
const asTimestamp = (value: unknown): number => {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
type ProjectSortOrder = 'name' | 'date';
const readProjectSortOrder = (): ProjectSortOrder => {
if (typeof window === 'undefined') return 'name';
const raw = window.localStorage.getItem('pilotdeck-settings');
if (!raw) return 'name';
try {
const parsed = JSON.parse(raw) as { projectSortOrder?: unknown };
return parsed.projectSortOrder === 'date' ? 'date' : 'name';
} catch {
return 'name';
}
};
const useProjectSortOrder = (): ProjectSortOrder => {
const [order, setOrder] = useState<ProjectSortOrder>(() => readProjectSortOrder());
useEffect(() => {
const refresh = () => setOrder(readProjectSortOrder());
const onStorage = (event: StorageEvent) => {
if (event.key === 'pilotdeck-settings') refresh();
};
window.addEventListener('pilotdeck-settings-changed', refresh);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('pilotdeck-settings-changed', refresh);
window.removeEventListener('storage', onStorage);
};
}, []);
return order;
};
const projectLastActivity = (project: Project): number => {
const buckets: ProjectSession[][] = [
Array.isArray(project.sessions) ? project.sessions : [],
];
let latest = 0;
for (const list of buckets) {
for (const session of list) {
const ts = Math.max(
asTimestamp(session.lastActivity),
asTimestamp(session.updated_at),
asTimestamp(session.createdAt),
asTimestamp(session.created_at),
);
if (ts > latest) latest = ts;
}
}
return latest;
};
type FlatSession = {
session: ProjectSession;
sessionId: string;
lastActivity: number;
};
const collectSessionsForProject = (project: Project): FlatSession[] => {
const sessions = Array.isArray(project.sessions) ? project.sessions : [];
return sessions
.map((session) => ({
session,
sessionId: session.id,
lastActivity: Math.max(
asTimestamp(session.lastActivity),
asTimestamp(session.updated_at),
asTimestamp(session.createdAt),
asTimestamp(session.created_at),
),
}))
.sort((a, b) => b.lastActivity - a.lastActivity);
};
const formatRelative = (ts: number, t: TFunction): string => {
if (!ts) return '';
const diff = Date.now() - ts;
if (diff < 60_000) return t('sidebar:time.justNow', { defaultValue: 'just now' });
if (diff < 3_600_000) {
const minutes = Math.floor(diff / 60_000);
if (minutes === 1) return t('sidebar:time.oneMinuteAgo', { defaultValue: '1 min ago' });
return t('sidebar:time.minutesAgo', { count: minutes, defaultValue: `${minutes} mins ago` });
}
if (diff < 86_400_000) {
const hours = Math.floor(diff / 3_600_000);
if (hours === 1) return t('sidebar:time.oneHourAgo', { defaultValue: '1 hour ago' });
return t('sidebar:time.hoursAgo', { count: hours, defaultValue: `${hours} hours ago` });
}
const days = Math.floor(diff / 86_400_000);
if (days === 1) return t('sidebar:time.oneDayAgo', { defaultValue: '1 day ago' });
return t('sidebar:time.daysAgo', { count: days, defaultValue: `${days} days ago` });
};
type SessionIndicatorStatus = 'processing' | 'unread' | 'idle';
const SPINNER_DOTS = Array.from({ length: 8 }, (_, index) => index);
function SessionStatusIndicator({
status,
label,
}: {
status: SessionIndicatorStatus;
label: string;
}) {
if (status === 'processing') {
return (
<span
aria-label={label}
title={label}
className="relative block h-3 w-3 animate-spin"
>
{SPINNER_DOTS.map((dot) => (
<span
key={dot}
className="absolute left-1/2 top-1/2 h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-300"
style={{
transform: `translate(-50%, -50%) rotate(${dot * 45}deg) translateY(-4px)`,
opacity: 0.35 + dot * 0.08,
}}
/>
))}
</span>
);
}
return (
<span
aria-label={label}
title={label}
className={cn(
'block h-1.5 w-1.5 rounded-full',
status === 'unread'
? 'bg-blue-500 dark:bg-blue-400'
: 'bg-neutral-300 dark:bg-neutral-600',
)}
/>
);
}
export type SidebarV2Props = {
projects: Project[];
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeTab: AppTab;
isLoading: boolean;
processingSessions?: Set<string>;
unreadSessionIds?: Set<string>;
onSelectProject: (project: Project) => void;
onSelectSession: (project: Project, sessionId: string) => void;
onStartNewSession: (project: Project | null) => void;
onCreateProject: () => void;
onRequestDeleteProject: (project: Project) => void;
onRequestDeleteSession: (project: Project, session: ProjectSession) => void;
onShowSettings: () => void;
onDeselectProject?: () => void;
onResetProjectSessionPreview?: (projectName: string) => void;
onCollapse?: () => void;
onLoadMoreSessions?: (projectName: string) => void;
loadingMoreProjectIds?: Set<string>;
};
type SidebarContextMenu =
| {
kind: 'project';
project: Project;
x: number;
y: number;
}
| {
kind: 'session';
project: Project;
session: ProjectSession;
x: number;
y: number;
};
const CONTEXT_MENU_WIDTH = 176;
const CONTEXT_MENU_HEIGHT = 88;
const CONTEXT_MENU_MARGIN = 8;
const contextMenuPosition = (event: MouseEvent) => {
const maxX = window.innerWidth - CONTEXT_MENU_WIDTH - CONTEXT_MENU_MARGIN;
const maxY = window.innerHeight - CONTEXT_MENU_HEIGHT - CONTEXT_MENU_MARGIN;
return {
x: Math.max(CONTEXT_MENU_MARGIN, Math.min(event.clientX, maxX)),
y: Math.max(CONTEXT_MENU_MARGIN, Math.min(event.clientY, maxY)),
};
};
export default function SidebarV2({
projects,
selectedProject,
selectedSession,
activeTab,
isLoading,
processingSessions,
unreadSessionIds,
onSelectProject,
onSelectSession,
onStartNewSession,
onCreateProject,
onRequestDeleteProject,
onRequestDeleteSession,
onShowSettings,
onDeselectProject,
onResetProjectSessionPreview,
onCollapse,
onLoadMoreSessions,
loadingMoreProjectIds,
}: SidebarV2Props) {
const { t } = useTranslation();
const navigate = useNavigate();
useCustomNamesVersion();
const safeProjects = Array.isArray(projects) ? projects : [];
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renamingSession, setRenamingSession] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState<string>('');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set());
const [contextMenu, setContextMenu] = useState<SidebarContextMenu | null>(null);
const [collapsedSessionProjects, setCollapsedSessionProjects] = useState<Set<string>>(new Set());
const [draftSessionProjectName, setDraftSessionProjectName] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
const SIDEBAR_SECTION_STORAGE_KEY = 'sidebar-v2-active-section';
type SidebarSection = 'projects' | 'general';
const [activeSection, setActiveSection] = useState<SidebarSection>(() => {
if (typeof window === 'undefined') return 'projects';
const stored = window.localStorage.getItem(SIDEBAR_SECTION_STORAGE_KEY);
return stored === 'general' ? 'general' : 'projects';
});
useEffect(() => {
try {
window.localStorage.setItem(SIDEBAR_SECTION_STORAGE_KEY, activeSection);
} catch {
}
}, [activeSection]);
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_MAX_WIDTH = 480;
const SIDEBAR_DEFAULT_WIDTH = 248;
const SIDEBAR_WIDTH_STORAGE_KEY = 'sidebar-v2-width';
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return SIDEBAR_DEFAULT_WIDTH;
const stored = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
const parsed = stored ? Number(stored) : NaN;
if (!Number.isFinite(parsed)) return SIDEBAR_DEFAULT_WIDTH;
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, parsed));
});
const [isResizing, setIsResizing] = useState(false);
const handleResizeStart = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = sidebarWidth;
setIsResizing(true);
const onMove = (e: globalThis.MouseEvent) => {
const next = Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, startWidth + (e.clientX - startX)),
);
setSidebarWidth(next);
};
const onUp = () => {
setIsResizing(false);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
queueMicrotask(() => {
try {
const aside = document.querySelector<HTMLElement>('aside[data-sidebar-v2-root]');
const width = aside?.offsetWidth;
if (width && Number.isFinite(width)) {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(width)));
}
} catch {
}
});
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, [sidebarWidth]);
useEffect(() => {
if ((renamingProject || renamingSession) && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renamingProject, renamingSession]);
useEffect(() => {
if (!contextMenu) return undefined;
const closeContextMenu = () => setContextMenu(null);
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') closeContextMenu();
};
window.addEventListener('click', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
window.addEventListener('scroll', closeContextMenu, true);
window.addEventListener('keydown', closeOnEscape);
return () => {
window.removeEventListener('click', closeContextMenu);
window.removeEventListener('resize', closeContextMenu);
window.removeEventListener('scroll', closeContextMenu, true);
window.removeEventListener('keydown', closeOnEscape);
};
}, [contextMenu]);
useEffect(() => {
if (!selectedProject?.name) return;
setExpandedGroups((previous) => {
if (previous.has(selectedProject.name)) return previous;
const next = new Set(previous);
next.add(selectedProject.name);
return next;
});
}, [selectedProject?.name]);
useEffect(() => {
if (!draftSessionProjectName) return;
if (!selectedProject || selectedSession || selectedProject.name !== draftSessionProjectName) {
setDraftSessionProjectName(null);
}
}, [draftSessionProjectName, selectedProject, selectedSession]);
const generalProject =
safeProjects.find((project) => project.name === 'general' || project.displayName === 'general') ?? null;
const previousSelectedProjectNameRef = useRef<string | null>(null);
useEffect(() => {
const currentName = selectedProject?.name ?? null;
const previousName = previousSelectedProjectNameRef.current;
previousSelectedProjectNameRef.current = currentName;
if (!currentName) return;
if (currentName === previousName) return;
const nextSection: SidebarSection =
generalProject && currentName === generalProject.name ? 'general' : 'projects';
setActiveSection((current) => (current === nextSection ? current : nextSection));
}, [selectedProject?.name, generalProject]);
const projectSortOrder = useProjectSortOrder();
const otherProjects = useMemo(() => {
const remaining = safeProjects.filter((project) => project !== generalProject);
if (projectSortOrder === 'date') {
return [...remaining].sort((a, b) => {
const diff = projectLastActivity(b) - projectLastActivity(a);
if (diff !== 0) return diff;
return projectDisplayName(a).localeCompare(projectDisplayName(b));
});
}
return [...remaining].sort((a, b) =>
projectDisplayName(a).localeCompare(projectDisplayName(b), undefined, { sensitivity: 'base' }),
);
}, [safeProjects, generalProject, projectSortOrder]);
const allProjectGroupsExpanded = otherProjects.length > 0 && otherProjects.every((project) =>
expandedGroups.has(project.name),
);
const navToProject = useCallback(
(name: string) => navigate(`/p/${encodeURIComponent(name)}`),
[navigate],
);
const handleGeneralSectionClick = useCallback(() => {
setActiveSection('general');
if (!generalProject) return;
onResetProjectSessionPreview?.(generalProject.name);
if (selectedProject?.name !== generalProject.name) {
onSelectProject(generalProject);
}
navToProject(generalProject.name);
}, [generalProject, navToProject, onResetProjectSessionPreview, onSelectProject, selectedProject?.name]);
const handleProjectsSectionClick = useCallback(() => {
if (generalProject) {
onResetProjectSessionPreview?.(generalProject.name);
}
setActiveSection('projects');
}, [generalProject, onResetProjectSessionPreview]);
const toggleProjectExpanded = useCallback((project: Project) => {
setExpandedGroups((previous) => {
const next = new Set(previous);
if (next.has(project.name)) {
next.delete(project.name);
} else {
next.add(project.name);
}
return next;
});
}, []);
const toggleAllProjectGroups = useCallback(() => {
setExpandedGroups((previous) => {
const next = new Set(previous);
if (allProjectGroupsExpanded) {
otherProjects.forEach((project) => next.delete(project.name));
} else {
otherProjects.forEach((project) => next.add(project.name));
}
return next;
});
}, [allProjectGroupsExpanded, otherProjects]);
const ensureExpanded = useCallback((project: Project) => {
setExpandedGroups((previous) => {
if (previous.has(project.name)) return previous;
const next = new Set(previous);
next.add(project.name);
return next;
});
}, []);
const handleProjectClick = useCallback(
(project: Project) => {
if (renamingProject === project.name) return;
toggleProjectExpanded(project);
},
[renamingProject, toggleProjectExpanded],
);
const handleSessionClick = useCallback(
(project: Project, sessionId: string) => {
if (renamingSession === sessionId) return;
setDraftSessionProjectName(null);
onSelectSession(project, sessionId);
ensureExpanded(project);
},
[ensureExpanded, onSelectSession, renamingSession],
);
const handleNewSession = useCallback(
(event: MouseEvent, project: Project) => {
event.stopPropagation();
setDraftSessionProjectName(project.name);
ensureExpanded(project);
onStartNewSession(project);
navToProject(project.name);
},
[ensureExpanded, navToProject, onStartNewSession],
);
const openProjectContextMenu = useCallback(
(event: MouseEvent, project: Project, isGeneral: boolean) => {
if (isGeneral || renamingProject === project.name) return;
event.preventDefault();
event.stopPropagation();
const position = contextMenuPosition(event);
setContextMenu({
kind: 'project',
project,
x: position.x,
y: position.y,
});
},
[renamingProject],
);
const openSessionContextMenu = useCallback(
(event: MouseEvent, project: Project, session: ProjectSession) => {
if (renamingSession === session.id) return;
event.preventDefault();
event.stopPropagation();
const position = contextMenuPosition(event);
setContextMenu({
kind: 'session',
project,
session,
x: position.x,
y: position.y,
});
},
[renamingSession],
);
const beginRenameProject = useCallback((project: Project) => {
setContextMenu(null);
setRenamingSession(null);
setRenamingProject(project.name);
setRenameDraft(projectDisplayName(project));
}, []);
const beginRenameSession = useCallback((session: ProjectSession) => {
setContextMenu(null);
setRenamingProject(null);
setRenamingSession(session.id);
setRenameDraft(sessionDisplayTitle(session));
}, []);
const requestDeleteProject = useCallback(
(project: Project) => {
setContextMenu(null);
onRequestDeleteProject(project);
},
[onRequestDeleteProject],
);
const requestDeleteSession = useCallback(
(project: Project, session: ProjectSession) => {
setContextMenu(null);
onRequestDeleteSession(project, session);
},
[onRequestDeleteSession],
);
const handleContextRename = useCallback(() => {
if (!contextMenu) return;
if (contextMenu.kind === 'project') {
beginRenameProject(contextMenu.project);
} else {
beginRenameSession(contextMenu.session);
}
}, [beginRenameProject, beginRenameSession, contextMenu]);
const handleContextDelete = useCallback(() => {
if (!contextMenu) return;
if (contextMenu.kind === 'project') {
requestDeleteProject(contextMenu.project);
} else {
requestDeleteSession(contextMenu.project, contextMenu.session);
}
}, [contextMenu, requestDeleteProject, requestDeleteSession]);
const commitProjectRename = useCallback(() => {
if (!renamingProject) return;
setProjectCustomName(renamingProject, renameDraft);
setRenamingProject(null);
setRenameDraft('');
}, [renamingProject, renameDraft]);
const commitSessionRename = useCallback(() => {
if (!renamingSession) return;
setSessionCustomTitle(renamingSession, renameDraft);
setRenamingSession(null);
setRenameDraft('');
}, [renamingSession, renameDraft]);
const cancelRename = useCallback(() => {
setRenamingProject(null);
setRenamingSession(null);
setRenameDraft('');
}, []);
const handleRenameKey = useCallback(
(event: KeyboardEvent<HTMLInputElement>, kind: 'project' | 'session') => {
if (event.key === 'Enter') {
if (isImeEnterEvent(event)) {
return;
}
event.preventDefault();
if (kind === 'project') commitProjectRename();
else commitSessionRename();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelRename();
}
},
[cancelRename, commitProjectRename, commitSessionRename],
);
const renderSessionRows = (
project: Project,
options: { flat?: boolean } = {},
) => {
const COLLAPSED_SESSION_LIMIT = 5;
const allSessions = collectSessionsForProject(project).slice(0, 500);
const isCollapsed = collapsedSessionProjects.has(project.name);
const sessions = isCollapsed ? allSessions.slice(0, COLLAPSED_SESSION_LIMIT) : allSessions;
const hiddenLoadedCount = isCollapsed ? Math.max(0, allSessions.length - COLLAPSED_SESSION_LIMIT) : 0;
const hasOptimisticSession = allSessions.some(({ session }) =>
typeof session.id === 'string' && session.id.startsWith('new-session-'),
);
const showDraftSession =
draftSessionProjectName === project.name &&
selectedProject?.name === project.name &&
activeTab === 'chat' &&
!selectedSession &&
!hasOptimisticSession;
const hasMoreSessions = Boolean(project.sessionMeta?.hasMore);
const isLoadingMore = Boolean(loadingMoreProjectIds?.has(project.name));
const totalSessions =
typeof project.sessionMeta?.total === 'number' ? project.sessionMeta.total : null;
const remaining =
totalSessions !== null ? Math.max(0, totalSessions - allSessions.length) : null;
const containerClass = options.flat ? 'space-y-0.5' : 'ml-6 space-y-0.5';
return (
<div className={containerClass}>
{showDraftSession ? (
<button
type="button"
onClick={(event) => handleNewSession(event, project)}
className="block w-full rounded-md bg-neutral-200/70 px-2 py-1 text-left text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
<div className="truncate text-[12.5px]">
{t('sidebar:sessions.newSession', { defaultValue: 'New Session' })}
</div>
<div className="text-[11px] text-neutral-500 dark:text-neutral-400">
{t('sidebar:sessions.unsaved', { defaultValue: 'Not saved yet' })}
</div>
</button>
) : null}
{sessions.length > 0 ? (
sessions.map(({ session, sessionId, lastActivity }) => {
const isSessionActive =
selectedProject?.name === project.name &&
selectedSession?.id === sessionId &&
activeTab === 'chat';
const isSessionRenaming = renamingSession === sessionId;
// Optimistic placeholder rows are not yet backed by a real
// session id on the server, so clicking / renaming / deleting
// them is meaningless until the server's `projects_updated`
// swaps in the real id (typically within ~300ms).
const isOptimisticRow =
typeof sessionId === 'string' && sessionId.startsWith('new-session-');
// Optimistic rows always appear "processing" — the user just
// submitted; the agent is always running for them.
const indicatorStatus: SessionIndicatorStatus = isOptimisticRow
? 'processing'
: processingSessions?.has(sessionId)
? 'processing'
: unreadSessionIds?.has(sessionId)
? 'unread'
: 'idle';
const indicatorLabel =
indicatorStatus === 'processing'
? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' })
: indicatorStatus === 'unread'
? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' })
: t('sidebar:sessions.idle', { defaultValue: 'No unread messages' });
return (
<div
key={sessionId}
onContextMenu={(event) =>
isOptimisticRow ? undefined : openSessionContextMenu(event, project, session)
}
className={cn(
'group/session relative w-full rounded-md transition-colors',
isSessionActive
? 'bg-neutral-200/70 dark:bg-neutral-800'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800',
)}
>
{isSessionRenaming ? (
<div className="flex items-center px-2 py-1">
<input
ref={renameInputRef}
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onBlur={commitSessionRename}
onKeyDown={(event) => handleRenameKey(event, 'session')}
onClick={(event) => event.stopPropagation()}
placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string}
className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
) : (
<button
type="button"
onClick={
isOptimisticRow
? undefined
: () => handleSessionClick(project, sessionId)
}
disabled={isOptimisticRow}
className={cn(
'flex w-full items-start gap-2 px-2 py-1 text-left',
isOptimisticRow && 'cursor-default',
)}
>
<span className="flex h-[18px] w-3 shrink-0 items-center justify-center pt-[3px]">
<SessionStatusIndicator
status={indicatorStatus}
label={indicatorLabel}
/>
</span>
<div className="min-w-0 flex-1">
<div
className={cn(
'truncate text-[12.5px] text-neutral-900 dark:text-neutral-100',
isOptimisticRow && 'italic text-neutral-600 dark:text-neutral-300',
)}
>
{sessionDisplayTitle(session)}
</div>
<div className="text-[11px] text-neutral-500 dark:text-neutral-400">
{isOptimisticRow
? t('sidebar:sessions.sending', { defaultValue: 'Sending…' })
: formatRelative(lastActivity, t)}
</div>
</div>
</button>
)}
</div>
);
})
) : (
<div className="px-2 py-1 text-[11px] text-neutral-500 dark:text-neutral-400">
{t('sidebar:sessions.noSessions', { defaultValue: 'No sessions yet' })}
</div>
)}
{((isCollapsed && hiddenLoadedCount > 0) || (!isCollapsed && hasMoreSessions && onLoadMoreSessions)) ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (isLoadingMore) return;
if (isCollapsed) {
setCollapsedSessionProjects((prev) => {
const next = new Set(prev);
next.delete(project.name);
return next;
});
} else if (onLoadMoreSessions) {
onLoadMoreSessions(project.name);
}
}}
disabled={isLoadingMore}
className={cn(
'block w-full rounded-md px-2 py-1 text-left text-[11px] transition-colors',
isLoadingMore
? 'text-neutral-400 dark:text-neutral-500'
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200',
)}
>
{isLoadingMore
? t('sidebar:sessions.loadingMore', { defaultValue: 'Loading more…' })
: (() => {
const totalMore = hiddenLoadedCount + (remaining !== null && remaining > 0 ? remaining : 0);
return totalMore > 0
? t('sidebar:sessions.showMoreCount', {
count: totalMore,
defaultValue: `Show more (${totalMore})`,
})
: t('sidebar:sessions.showMore', { defaultValue: 'Show more sessions' });
})()}
</button>
) : null}
{!isCollapsed && allSessions.length > COLLAPSED_SESSION_LIMIT ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setCollapsedSessionProjects((prev) => {
const next = new Set(prev);
next.add(project.name);
return next;
});
}}
className="block w-full rounded-md px-2 py-1 text-left text-[11px] transition-colors text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
{t('sidebar:sessions.showLess', { defaultValue: 'Show less' })}
</button>
) : null}
</div>
);
};
const renderProjectGroup = (project: Project, options: { isGeneral?: boolean } = {}) => {
const isGeneral = Boolean(options.isGeneral);
const isSelected = project.name === selectedProject?.name;
const isExpanded = expandedGroups.has(project.name);
const isRenaming = renamingProject === project.name;
const label = isGeneral
? t('sidebar:general.name', { defaultValue: 'General' })
: projectDisplayName(project);
return (
<div key={project.name} className="space-y-0.5">
<div
onContextMenu={(event) => openProjectContextMenu(event, project, isGeneral)}
className={cn(
'group/project flex h-8 w-full items-center rounded-lg pr-1 text-[13px] transition-colors',
isSelected
? 'bg-neutral-200/70 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
: 'text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800',
)}
>
{isRenaming && !isGeneral ? (
<div className="flex h-full min-w-0 flex-1 items-center gap-1.5 pl-2 pr-1">
<Folder className="h-3.5 w-3.5 shrink-0 text-neutral-500 dark:text-neutral-400" strokeWidth={1.75} />
<input
ref={renameInputRef}
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onBlur={commitProjectRename}
onKeyDown={(event) => handleRenameKey(event, 'project')}
onClick={(event) => event.stopPropagation()}
placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string}
className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
) : (
<button
type="button"
onClick={() => handleProjectClick(project)}
className="flex h-full min-w-0 flex-1 items-center gap-1.5 rounded-l-lg pl-1.5 pr-1 text-left"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-neutral-500 transition-transform dark:text-neutral-400',
isExpanded && 'rotate-90',
)}
strokeWidth={1.75}
/>
<Folder
className={cn(
'h-3.5 w-3.5 shrink-0',
isSelected
? 'text-neutral-900 dark:text-neutral-100'
: 'text-neutral-500 dark:text-neutral-400',
)}
strokeWidth={1.75}
/>
<span className="flex-1 truncate">{label}</span>
</button>
)}
{!isRenaming ? (
<div
className={cn(
'ml-1 flex shrink-0 items-center gap-0.5 transition-opacity',
'[@media(hover:none)]:opacity-100',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover/project:opacity-100 focus-within:opacity-100',
)}
>
<button
type="button"
onClick={(event) => handleNewSession(event, project)}
aria-label={t('sidebar:tooltips.newChat', { defaultValue: 'New Chat' }) as string}
title={t('sidebar:tooltips.newChat', { defaultValue: 'New Chat' }) as string}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md',
'text-neutral-500 hover:bg-neutral-200/70 hover:text-neutral-900',
'dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-100',
)}
>
<MessageSquarePlus className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</div>
) : null}
</div>
{isExpanded ? renderSessionRows(project) : null}
</div>
);
};
return (
<aside
data-sidebar-v2-root
style={{ width: `${sidebarWidth}px` }}
className={cn(
// On mobile the parent wraps this aside in an overlay constrained
// to 85vw, so force the inline width style off with !w-full there.
'relative flex h-full shrink-0 flex-col max-md:!w-full',
'bg-neutral-50 text-neutral-900',
'dark:bg-neutral-900 dark:text-neutral-100',
'border-r border-neutral-200 dark:border-neutral-800',
)}
>
<div className="flex h-16 items-center justify-between pl-2 pr-4">
<button
type="button"
onClick={() => {
if (onDeselectProject) {
onDeselectProject();
} else {
navigate('/');
}
}}
aria-label="PilotDeck"
title="PilotDeck"
className="flex min-w-0 shrink items-center gap-2 rounded-md p-1 transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300 dark:focus-visible:ring-neutral-700"
>
<img
src={pilotdeckLogoLight}
alt="PilotDeck"
className="h-7 w-auto max-w-[150px] select-none object-contain dark:hidden"
draggable={false}
/>
<img
src={pilotdeckLogoDark}
alt="PilotDeck"
className="hidden h-7 w-auto max-w-[150px] select-none object-contain dark:block"
draggable={false}
/>
</button>
{onCollapse ? (
<button
type="button"
onClick={onCollapse}
aria-label={t('sidebar:tooltips.hideSidebar', { defaultValue: 'Hide sidebar' }) as string}
title={t('sidebar:tooltips.hideSidebar', { defaultValue: 'Hide sidebar' }) as string}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<PanelLeftClose className="h-4 w-4" strokeWidth={1.75} />
</button>
) : null}
</div>
{/* Section toggle: a thin pill control sitting just above the scroll
area, so it doesn't move while the list scrolls. Mirrors the look of
familiar two-tab segmented controls (e.g. iOS, ProseMirror). */}
<div className="px-3 pt-3 pb-1">
<div
role="tablist"
aria-label={t('sidebar:sectionToggle.label', { defaultValue: 'Sidebar section' }) as string}
className="flex w-full rounded-md bg-neutral-100 p-0.5 dark:bg-neutral-900"
>
<button
type="button"
role="tab"
aria-selected={activeSection === 'projects'}
onClick={handleProjectsSectionClick}
className={cn(
'flex-1 rounded text-[12px] font-medium transition-colors',
'h-7 leading-none',
activeSection === 'projects'
? 'bg-white text-neutral-900 shadow-sm dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200',
)}
>
{t('sidebar:projects.title', { defaultValue: 'Projects' })}
</button>
<button
type="button"
role="tab"
aria-selected={activeSection === 'general'}
onClick={handleGeneralSectionClick}
className={cn(
'flex-1 rounded text-[12px] font-medium transition-colors',
'h-7 leading-none',
activeSection === 'general'
? 'bg-white text-neutral-900 shadow-sm dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200',
)}
>
{t('sidebar:general.title', { defaultValue: 'General' })}
</button>
</div>
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{isLoading && safeProjects.length === 0 ? (
<div className="px-2 py-4 text-xs text-neutral-500 dark:text-neutral-400">
{t('sidebar:sessions.loading', { defaultValue: 'Loading...' })}
</div>
) : activeSection === 'projects' ? (
<section className="pt-2">
<div className="flex items-center px-3 pb-1">
<span className="flex-1 text-[11px] font-medium uppercase tracking-[0.04em] text-neutral-500/90 dark:text-neutral-400/80">
{t('sidebar:projects.title', { defaultValue: 'Projects' })}
</span>
<button
type="button"
onClick={toggleAllProjectGroups}
disabled={otherProjects.length === 0}
aria-label={
allProjectGroupsExpanded
? t('sidebar:projects.collapseAll', { defaultValue: 'Collapse all projects' }) as string
: t('sidebar:projects.expandAll', { defaultValue: 'Expand all projects' }) as string
}
title={
allProjectGroupsExpanded
? t('sidebar:projects.collapseAll', { defaultValue: 'Collapse all projects' }) as string
: t('sidebar:projects.expandAll', { defaultValue: 'Expand all projects' }) as string
}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 disabled:opacity-40 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
{allProjectGroupsExpanded ? (
<ChevronsDownUp className="h-3.5 w-3.5" strokeWidth={1.75} />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" strokeWidth={1.75} />
)}
</button>
<button
type="button"
onClick={onCreateProject}
aria-label={t('sidebar:projects.newProject', { defaultValue: 'New Project' }) as string}
title={t('sidebar:projects.newProject', { defaultValue: 'New Project' }) as string}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</div>
{otherProjects.length === 0 ? (
<div className="px-3 py-1 text-[11px] text-neutral-500 dark:text-neutral-400">
{t('sidebar:projects.noProjects', { defaultValue: 'No projects found' })}
</div>
) : (
<div className="space-y-0.5">
{otherProjects.map((project) => renderProjectGroup(project))}
</div>
)}
</section>
) : (
<section className="pt-2">
{generalProject ? (
<>
<div className="flex items-center px-3 pb-1">
<span className="flex-1 text-[11px] font-medium uppercase tracking-[0.04em] text-neutral-500/90 dark:text-neutral-400/80">
{t('sidebar:general.title', { defaultValue: 'General' })}
</span>
<button
type="button"
onClick={(event) => handleNewSession(event, generalProject)}
aria-label={t('sidebar:tooltips.newChat', { defaultValue: 'New Chat' }) as string}
title={t('sidebar:tooltips.newChat', { defaultValue: 'New Chat' }) as string}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<MessageSquarePlus className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</div>
<div className="px-1">
{renderSessionRows(generalProject, { flat: true })}
</div>
</>
) : (
<div className="px-3 py-1 text-[11px] text-neutral-500 dark:text-neutral-400">
{t('sidebar:general.missing', {
defaultValue: 'No general workspace found',
})}
</div>
)}
</section>
)}
</div>
<div className="border-t border-neutral-200 px-2 py-2 dark:border-neutral-800">
<button
type="button"
onClick={onShowSettings}
aria-label={t('sidebar:actions.settings', { defaultValue: 'Settings' }) as string}
title={t('sidebar:actions.settings', { defaultValue: 'Settings' }) as string}
className="flex h-9 w-full items-center justify-start gap-2 rounded-lg px-6 text-[13px] font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<SettingsIcon className="h-4 w-4" strokeWidth={1.75} />
<span>{t('sidebar:actions.settings', { defaultValue: 'Settings' })}</span>
</button>
</div>
{contextMenu ? (
<div
role="menu"
aria-label={t('sidebar:contextMenu.label', { defaultValue: 'Context menu' }) as string}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
className={cn(
'fixed z-50 w-44 rounded-lg border border-neutral-200 bg-white p-1 shadow-lg',
'dark:border-neutral-700 dark:bg-neutral-900',
)}
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
type="button"
role="menuitem"
onClick={handleContextRename}
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[13px]',
'text-neutral-800 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800',
)}
>
<Pencil className="h-3.5 w-3.5 shrink-0 text-neutral-500 dark:text-neutral-400" strokeWidth={1.75} />
<span>{t('sidebar:actions.rename', { defaultValue: 'Rename' })}</span>
</button>
<button
type="button"
role="menuitem"
onClick={handleContextDelete}
className={cn(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[13px]',
'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40',
)}
>
<Trash2 className="h-3.5 w-3.5 shrink-0" strokeWidth={1.75} />
<span>{t('sidebar:actions.delete', { defaultValue: 'Delete' })}</span>
</button>
</div>
) : null}
{
border, 4px wide; expands hit area on hover and shows a faint accent
while dragging. Hidden on mobile (the overlay sidebar isn't resizable). */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar:tooltips.resize', { defaultValue: 'Resize sidebar' }) as string}
title={t('sidebar:tooltips.resize', { defaultValue: 'Drag to resize' }) as string}
onMouseDown={handleResizeStart}
onDoubleClick={() => {
setSidebarWidth(SIDEBAR_DEFAULT_WIDTH);
try {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(SIDEBAR_DEFAULT_WIDTH));
} catch {
}
}}
className={cn(
'absolute inset-y-0 right-0 z-10 hidden w-1 cursor-col-resize select-none md:block',
'transition-colors duration-150',
isResizing
? 'bg-blue-500/60'
: 'hover:bg-neutral-300/70 dark:hover:bg-neutral-700/70',
)}
/>
{
consistent and we don't accidentally select text in the main pane. */}
{isResizing ? (
<div
className="fixed inset-0 z-[60] cursor-col-resize"
style={{ userSelect: 'none' }}
/>
) : null}
</aside>
);
}