/** * Single-column sidebar, top to bottom: * Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center / * Trace) -> Session area with two grouping modes (a small toggle in the section header; the * choice and each Project's group collapse state persist in localStorage): by Workspace (the * default; groups loaded Sessions by their * Workspace path, auto temp directories merged into one trailing group, header "+" starts a * draft in that Workspace) or by Agent (group header = Agent name + new chat + Agent settings; * shows all Agents, including empty groups) -> bottom user config (theme / language / logout). * Desktop keeps it pinned as the left column; mobile puts the whole thing in a drawer. * New chats always enter draft state (/chat/new, route state specifies the Agent and optionally * the Workspace): Model / Workspace / approval mode are all chosen on the draft input card, so * there's no longer a separate "quick / advanced" pair of new-chat dialogs. * Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color. */ import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { NavLink, useMatch, useNavigate } from "react-router"; import type { SessionInfo } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; import { useAuth } from "../../state/auth"; import { useLocale } from "../../state/locale"; import type { LangPref } from "../../state/locale"; import { ACCENT_SWATCHES, useTheme } from "../../state/theme"; import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme"; import { agentDisplayName, projectDisplayName, useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; import { groupSessionsByWorkspace, workspaceGroupKey } from "../../lib/session-grouping"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; import { Truncated } from "../ui/truncated"; import { Badge } from "../ui/badge"; import { Modal } from "../ui/modal"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Segmented } from "../ui/segmented"; import { SkeletonList } from "../ui/skeleton"; import { DRAFT_SESSION_ID } from "../../features/chat/chat-page"; import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache"; import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs"; import { ChangePasswordDialog } from "../account/change-password-dialog"; function Icon({ d, size = 16 }: { d: string; size?: number }) { return ( ); } /** Dropdown caret (used by the Project switcher; distinct from the collapse-indicator Chevron). */ function DropdownCaret() { return ( ); } const NAV_ICONS = { agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01", /** Skill library (an open book: two pages + spine). */ skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z", models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3", usage: "M4 20V10m6 10V4m6 16v-7m4 7H2", traces: "M4 6h16M4 12h10M4 18h13", /** Benchmark center (a trophy: cup + two handles + base). */ benchmark: "M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8", } as const; /** Standard gear (lucide settings): full tooth outline + center circle, crisp and undistorted at 16px. */ const GEAR_ICON = "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"; /** Folder outline (same glyph as the draft page's Workspace pill). */ const FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"; const menuItemClass = "block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"; /** Grouping mode of the Session list (persisted; Workspace is the default). */ type GroupMode = "workspace" | "agent"; const GROUP_MODE_KEY = "penguin.sidebarGroupMode"; function initialGroupMode(): GroupMode { return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace"; } /** * Collapsed-group persistence (survives a refresh), one key per Project — group keys * are Agent ids / Workspace paths, which are Project-scoped. Both grouping modes share * one set (their key spaces never collide); stray keys left by deleted Agents or * Workspaces are harmless (never matched) and the per-Project sets stay tiny. */ const collapsedGroupsKey = (projectId: string) => `penguin.sidebarCollapsedGroups.${projectId}`; /** Reads the persisted collapsed set (corrupted storage degrades to "all expanded"). */ function loadCollapsedGroups(projectId: string | null): ReadonlySet { if (!projectId) return new Set(); try { const parsed: unknown = JSON.parse(localStorage.getItem(collapsedGroupsKey(projectId)) ?? "[]"); return new Set( Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [], ); } catch { return new Set(); } } function saveCollapsedGroups(projectId: string | null, next: ReadonlySet): void { if (!projectId) return; try { localStorage.setItem(collapsedGroupsKey(projectId), JSON.stringify([...next])); } catch { /* best-effort persistence (quota/private mode) */ } } /** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */ function StatusDot({ session }: { session: SessionInfo }) { if (session.status === "running") { return ( ); } if (session.status === "compacting") { return ( ); } return null; } export function Sidebar({ onNavigate, onCollapse, }: { onNavigate?: () => void; onCollapse?: () => void; }) { const navigate = useNavigate(); const { user, logout } = useAuth(); const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } = useTheme(); const { lang, setLang } = useLocale(); const { projects, currentProject, setCurrentProjectId, reloadProjects, agents, currentAgent, setCurrentAgentId, } = useProject(); const { sessions, byAgent, loading, remove, replace } = useSessions(); const chatMatch = useMatch("/chat/:sessionId"); const activeSessionId = chatMatch?.params.sessionId ?? null; const [projectOpen, setProjectOpen] = useState(false); const [userOpen, setUserOpen] = useState(false); const [createProjectOpen, setCreateProjectOpen] = useState(false); const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false); const currentProjectId = currentProject?.projectId ?? null; /** Grouping mode of the Session list (Workspace by default; the choice persists across sessions). */ const [groupMode, setGroupModeState] = useState(initialGroupMode); /** Collapsed groups (expanded by default), keyed by Agent id or Workspace group key depending on the mode; persisted per Project. */ const [collapsedGroups, setCollapsedGroups] = useState>(() => loadCollapsedGroups(currentProjectId), ); // Project resolved on first load / switched: swap in that Project's persisted collapse set. useEffect(() => { setCollapsedGroups(loadCollapsedGroups(currentProjectId)); }, [currentProjectId]); /** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */ const [openArchived, setOpenArchived] = useState>(new Set()); /** Session pending delete confirmation (null = none). */ const [deletingSession, setDeletingSession] = useState(null); const [deletingBusy, setDeletingBusy] = useState(false); const [deleteError, setDeleteError] = useState(null); /** Session currently being renamed (null = none) and the title being typed. */ const [renamingSession, setRenamingSession] = useState(null); const [renameText, setRenameText] = useState(""); const [renameBusy, setRenameBusy] = useState(false); const [renameError, setRenameError] = useState(null); const setGroupMode = (mode: GroupMode) => { localStorage.setItem(GROUP_MODE_KEY, mode); setGroupModeState(mode); }; /** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */ const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]); /** Group key of a Session under the current mode (collapse / archived-open state). */ const sessionGroupKey = (s: SessionInfo) => groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace); const toggleGroup = (key: string) => { // Computed outside the state updater (theme.tsx convention): the persistence write is a // side effect, and updaters must stay pure (double-invoked in StrictMode). const next = new Set(collapsedGroups); if (next.has(key)) next.delete(key); else next.add(key); setCollapsedGroups(next); saveCollapsedGroups(currentProjectId, next); }; const toggleArchivedGroup = (key: string) => setOpenArchived((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); /** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */ const toggleArchive = async (s: SessionInfo) => { // Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back. if (!s.archived && s.sessionId === activeSessionId) { setOpenArchived((prev) => new Set(prev).add(sessionGroupKey(s))); } try { const res = await api.patchSession(s.sessionId, { archived: !s.archived }); replace(res.session); } catch { /* Ignore: non-critical operation */ } }; const confirmRename = async () => { if (!renamingSession) return; const title = renameText.trim(); if (!title) return; setRenameBusy(true); setRenameError(null); try { const res = await api.patchSession(renamingSession.sessionId, { title }); replace(res.session); setRenamingSession(null); } catch (e) { setRenameError(e instanceof ApiError ? e.message : S.common.unknownError); } finally { setRenameBusy(false); } }; const confirmDeleteSession = async () => { if (!deletingSession) return; setDeletingBusy(true); setDeleteError(null); const target = deletingSession; try { await api.deleteSession(target.sessionId); remove(target.sessionId); // The session is gone, so clear its input draft too (no orphaned keys left in localStorage; keys are scoped per user, #68). if (user) clearDraft(sessionDraftKey(user.userId, target.sessionId)); setDeletingSession(null); // The deleted session was the one open: jump to another **unarchived** Session in the same // group, otherwise fall back to the chat home page (never jump into an archived session — // it's hidden by default, so landing there would look like the chat vanished into thin air). if (activeSessionId === target.sessionId) { const rest = (byAgent.get(target.agentId) ?? []).filter( (s) => s.sessionId !== target.sessionId && !s.archived, ); navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat"); } } catch (e) { setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError); } finally { setDeletingBusy(false); } }; const go = (to: string) => { navigate(to); onNavigate?.(); }; /** * New chat: enters draft state (/chat/new) without creating a Session — Model / Workspace / * approval mode are all chosen on the draft input card, and the Session is only actually * created when the first message is sent. The route state explicitly carries the target * Agent: the agent-mode group header's "+" uses that group's Agent, while the menu's "New * chat" uses default_agent; this explicit intent overrides the previously selected Agent in * the draft cache (the rest of the draft content, such as the message body, is preserved). * The workspace-mode group header's "+" additionally carries that group's Workspace path * ("" = the auto temp directory), pre-filling the draft's Workspace selection the same way. */ const newChat = (agentId?: string, workspace?: string) => { if (agentId) setCurrentAgentId(agentId); const state = { ...(agentId ? { agentId } : {}), ...(workspace !== undefined ? { workspace } : {}), }; navigate(`/chat/${DRAFT_SESSION_ID}`, Object.keys(state).length > 0 ? { state } : undefined); onNavigate?.(); }; /** Target of the menu's "New chat": default_agent, falling back to the first Agent (if the list isn't ready yet, resolution is deferred to the draft page). */ const defaultAgentId = (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId; /** A Session always needs an Agent, so the workspace-mode "+" uses the current Agent, falling back to default_agent. */ const workspaceNewChatAgentId = currentAgent?.agentId ?? defaultAgentId; const openSession = (s: SessionInfo) => { // Cross-group click: the current Agent follows this Session's own Agent. setCurrentAgentId(s.agentId); go(`/chat/${s.sessionId}`); }; /** agentId → display name (row hint tooltips in workspace mode). */ const agentNameById = useMemo( () => new Map(agents.map((a) => [a.agentId, agentDisplayName(a)])), [agents], ); /** Session rows shared by both modes; withAgentHint adds a small Agent avatar per row (workspace mode, where the group no longer names the Agent). */ const renderRows = (rows: SessionInfo[], withAgentHint: boolean) => (
    {rows.map((s) => ( { setRenameError(null); setRenameText(x.title ?? ""); setRenamingSession(x); }} onDelete={(x) => { setDeleteError(null); setDeletingSession(x); }} onToggleArchive={(x) => void toggleArchive(x)} /> ))}
); /** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */ const renderGroupBody = ( groupKey: string, activeList: SessionInfo[], archivedList: SessionInfo[], withAgentHint: boolean, ) => { const archivedOpen = openArchived.has(groupKey); return ( <> {activeList.length === 0 && archivedList.length === 0 ? (

{S.chat.noSessions}

) : ( renderRows(activeList, withAgentHint) )} {/* Archived group (collapsed by default) */} {archivedList.length > 0 && (
{archivedOpen && renderRows(archivedList, withAgentHint)}
)} ); }; const navItems: Array<{ to: string; label: string; icon: string }> = [ { to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents }, { to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills }, { to: "/models", label: S.nav.models, icon: NAV_ICONS.models }, { to: "/usage", label: S.nav.usage, icon: NAV_ICONS.usage }, { to: "/traces", label: S.nav.traces, icon: NAV_ICONS.traces }, { to: "/benchmark", label: S.nav.benchmark, icon: NAV_ICONS.benchmark }, ]; const themeOptions: ReadonlyArray<{ value: ThemeMode; label: string }> = [ { value: "light", label: S.settings.themeLight }, { value: "dark", label: S.settings.themeDark }, { value: "system", label: S.settings.followSystem }, ]; const langOptions: ReadonlyArray<{ value: LangPref; label: string }> = [ { value: "en", label: S.settings.langEn }, { value: "zh", label: S.settings.langZh }, { value: "system", label: S.settings.followSystem }, ]; const fontOptions: ReadonlyArray<{ value: FontScale; label: string }> = [ { value: "sm", label: S.settings.fontSmall }, { value: "md", label: S.settings.fontMedium }, { value: "lg", label: S.settings.fontLarge }, ]; const currencyOptions: ReadonlyArray<{ value: Currency; label: string }> = [ { value: "USD", label: S.models.currencyUsd }, { value: "CNY", label: S.models.currencyCny }, ]; return (
{/* Project switcher (+ collapse sidebar) */}
{onCollapse && ( )} setProjectOpen(!projectOpen)} className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-base font-semibold transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800" > {currentProject ? projectDisplayName(currentProject) : S.common.loading} } > {projects.map((p) => ( ))}
{currentProject && ( )}
{/* Fixed nav (new chat pinned at top: default_agent draft): no background fill, shares the same gray hover/active styling as nav items, distinguished only by its top position and font-medium; shows the same gray active state while on the draft page. */} {/* Session area (scrollable): grouped by Workspace (default) or by Agent */}
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage) */}
{S.chat.sessionList}
{( [ { value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace }, { value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent }, ] as const ).map((opt) => ( ))}
{groupMode === "agent" ? ( loading && agents.length === 0 ? ( ) : ( agents.map((agent) => { const list = byAgent.get(agent.agentId) ?? []; const activeList = list.filter((s) => !s.archived); const archivedList = list.filter((s) => s.archived); const collapsed = collapsedGroups.has(agent.agentId); return (
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
{collapsed ? null : renderGroupBody(agent.agentId, activeList, archivedList, false)}
); }) ) ) : loading && sessions.length === 0 ? ( ) : workspaceGroups.length === 0 ? (

{S.chat.noSessions}

) : ( workspaceGroups.map((group) => { const activeList = group.sessions.filter((s) => !s.archived); const archivedList = group.sessions.filter((s) => s.archived); const collapsed = collapsedGroups.has(group.key); return (
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + new chat in this Workspace */}
{/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */}
{collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
); }) )}
{/* Bottom user config */}
setUserOpen(!userOpen)} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800" > {(user?.userId ?? "?").slice(0, 1).toUpperCase()} {user?.userId} {user?.isAdmin && ( {S.auth.admin} )} } >
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */} {user?.isAdmin && ( )}
setChangePasswordOpen(false)} /> setCreateProjectOpen(false)} onCreated={(projectId) => { setCreateProjectOpen(false); void reloadProjects().then(() => setCurrentProjectId(projectId)); }} /> {currentProject && ( setProjectSettingsOpen(false)} /> )} {/* Rename chat */} (renameBusy ? undefined : setRenamingSession(null))} footer={ <> } > setRenameText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename(); }} /> {renameError && (

{renameError}

)}
{/* Delete chat confirmation */} (deletingBusy ? undefined : setDeletingSession(null))} footer={ <> } >

{deletingSession ? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle) : ""}

{deleteError && (

{deleteError}

)}
); } /** Single Session row: title + status dot/approval badge + hover action group (rename, archive/unarchive, delete). */ function SessionRow({ s, active, agentHint, onOpen, onRename, onDelete, onToggleArchive, }: { s: SessionInfo; active: boolean; /** Agent display name; when set (workspace mode) a small avatar keeps the Agent context visible on the row. */ agentHint?: string; onOpen: (s: SessionInfo) => void; onRename: (s: SessionInfo) => void; onDelete: (s: SessionInfo) => void; onToggleArchive: (s: SessionInfo) => void; }) { const actionBtn = "flex h-6 w-6 shrink-0 items-center justify-center rounded text-gray-400 opacity-0 transition-all duration-150 focus-visible:opacity-100 group-hover:opacity-100"; return (
  • {/* Action group: rename + archive/unarchive + delete */}
  • ); } function SettingRow({ label, children }: { label: string; children: ReactNode }) { return (

    {label}

    {children}
    ); } /** Accent color picker: a row of swatches, with a ring on the selected one. */ function AccentPicker({ value, onChange }: { value: Accent; onChange: (a: Accent) => void }) { return (
    {ACCENT_SWATCHES.map((s) => (
    ); }