mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Trace is a low-frequency diagnostic feature, so its entry points no longer sit in primary chrome: - Remove the Trace nav item above the session list in the sidebar - Remove the trace open/open-window buttons from the session header - Add a Trace tab in Settings between Token usage and Diagnostics, embedding the existing TraceList page full-bleed - Add settings.tab.trace and drop unused sidebar.traces across locales
1971 lines
85 KiB
TypeScript
1971 lines
85 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
|
import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
|
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
|
import type { SessionListItem } from '../../types/session'
|
|
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
|
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
|
import { getDesktopHost } from '../../lib/desktopHost'
|
|
import { publicAssetPath } from '../../lib/publicAsset'
|
|
|
|
const desktopHost = getDesktopHost()
|
|
const isDesktopRuntime = desktopHost.isDesktop
|
|
const canUseNativeDialogs = desktopHost.capabilities.dialogs
|
|
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
|
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
|
|
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
|
|
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
|
|
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
|
|
const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
|
|
const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
|
|
const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
|
|
const PROJECT_GROUP_VISIBLE_COUNT = 6
|
|
const PROJECT_GROUP_SCROLL_COUNT = 12
|
|
|
|
type SidebarProjectOrganization = 'project' | 'recentProject' | 'time'
|
|
type SidebarProjectSortBy = 'createdAt' | 'updatedAt'
|
|
type SidebarHeaderMenuType = 'main' | 'organize' | 'sort' | 'create'
|
|
|
|
type ProjectGroup = {
|
|
key: string
|
|
title: string
|
|
subtitle: string | null
|
|
workDir: string | undefined
|
|
sessions: SessionListItem[]
|
|
}
|
|
|
|
type SidebarProps = {
|
|
isMobile?: boolean
|
|
onRequestClose?: () => void
|
|
}
|
|
|
|
export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|
const t = useTranslation()
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const isLoading = useSessionStore((s) => s.isLoading)
|
|
const error = useSessionStore((s) => s.error)
|
|
const fetchSessions = useSessionStore((s) => s.fetchSessions)
|
|
const deleteSession = useSessionStore((s) => s.deleteSession)
|
|
const deleteSessions = useSessionStore((s) => s.deleteSessions)
|
|
const isBatchMode = useSessionStore((s) => s.isBatchMode)
|
|
const selectedSessionIds = useSessionStore((s) => s.selectedSessionIds)
|
|
const enterBatchMode = useSessionStore((s) => s.enterBatchMode)
|
|
const exitBatchMode = useSessionStore((s) => s.exitBatchMode)
|
|
const toggleSessionSelected = useSessionStore((s) => s.toggleSessionSelected)
|
|
const selectSessions = useSessionStore((s) => s.selectSessions)
|
|
const deselectSessions = useSessionStore((s) => s.deselectSessions)
|
|
const renameSession = useSessionStore((s) => s.renameSession)
|
|
const addToast = useUIStore((s) => s.addToast)
|
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const tabs = useTabStore((s) => s.tabs)
|
|
const chatSessions = useChatStore((s) => s.sessions)
|
|
const closeTab = useTabStore((s) => s.closeTab)
|
|
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
|
const [projectContextMenu, setProjectContextMenu] = useState<{ key: string; x: number; y: number } | null>(null)
|
|
const [projectHeaderMenu, setProjectHeaderMenu] = useState<{ type: SidebarHeaderMenuType; x: number; y: number } | null>(null)
|
|
const [projectHeaderSubmenu, setProjectHeaderSubmenu] = useState<{ type: 'organize' | 'sort'; x: number; y: number } | null>(null)
|
|
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
|
|
const [pendingBatchDeleteSessionIds, setPendingBatchDeleteSessionIds] = useState<string[] | null>(null)
|
|
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
|
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const [lastSelectedSessionId, setLastSelectedSessionId] = useState<string | null>(null)
|
|
const [expandedProjectKeys, setExpandedProjectKeys] = useState<Set<string>>(new Set())
|
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
|
const [projectOrder, setProjectOrder] = useState<string[]>(() => readStoredProjectOrder())
|
|
const [pinnedProjectKeys, setPinnedProjectKeys] = useState<Set<string>>(() => readStoredProjectPins())
|
|
const [hiddenProjectKeys, setHiddenProjectKeys] = useState<Set<string>>(() => readStoredProjectHidden())
|
|
const [projectOrganization, setProjectOrganizationState] = useState<SidebarProjectOrganization>(() => readStoredProjectOrganization())
|
|
const [projectSortBy, setProjectSortByState] = useState<SidebarProjectSortBy>(() => readStoredProjectSortBy())
|
|
const [draggingProjectKey, setDraggingProjectKey] = useState<string | null>(null)
|
|
const [projectDropTarget, setProjectDropTarget] = useState<{ key: string; position: 'before' | 'after' } | null>(null)
|
|
const suppressProjectClickRef = useRef<string | null>(null)
|
|
const sidebarPreferenceRevisionRef = useRef(0)
|
|
const refreshSessionsNow = useSessionListAutoRefresh(fetchSessions)
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return
|
|
if (!sidebarOpen) setContextMenu(null)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sidebarOpen])
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu && !projectContextMenu && !projectHeaderMenu && !projectHeaderSubmenu) return
|
|
const close = () => {
|
|
setContextMenu(null)
|
|
setProjectContextMenu(null)
|
|
setProjectHeaderMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
}
|
|
document.addEventListener('click', close)
|
|
return () => document.removeEventListener('click', close)
|
|
}, [contextMenu, projectContextMenu, projectHeaderMenu, projectHeaderSubmenu])
|
|
|
|
const filteredSessions = useMemo(() => {
|
|
let result = sessions
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase()
|
|
result = result.filter((s) => s.title.toLowerCase().includes(q))
|
|
}
|
|
return result
|
|
}, [sessions, searchQuery])
|
|
|
|
const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy])
|
|
const orderedProjectGroups = useMemo(
|
|
() => applyProjectOrder(projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy),
|
|
[projectGroups, projectOrder, pinnedProjectKeys, projectOrganization, projectSortBy],
|
|
)
|
|
const visibleProjectGroups = useMemo(() => {
|
|
if (hiddenProjectKeys.size === 0) return orderedProjectGroups
|
|
return orderedProjectGroups.filter((project) => (
|
|
!hiddenProjectKeys.has(project.key)
|
|
))
|
|
}, [hiddenProjectKeys, orderedProjectGroups])
|
|
const showInitialLoading = isLoading && sessions.length === 0
|
|
const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions])
|
|
const selectedCount = selectedSessionIds.size
|
|
const sessionsById = useMemo(
|
|
() => new Map(sessions.map((session) => [session.id, session])),
|
|
[sessions],
|
|
)
|
|
const runningSessionIds = useMemo(() => {
|
|
const ids = new Set<string>()
|
|
for (const tab of tabs) {
|
|
if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId)
|
|
}
|
|
for (const [sessionId, sessionState] of Object.entries(chatSessions)) {
|
|
if (sessionState.chatState !== 'idle') ids.add(sessionId)
|
|
}
|
|
return ids
|
|
}, [chatSessions, tabs])
|
|
const pendingBatchDeleteSessions = useMemo(
|
|
() => (pendingBatchDeleteSessionIds ?? [])
|
|
.map((sessionId) => sessionsById.get(sessionId))
|
|
.filter((session): session is SessionListItem => Boolean(session)),
|
|
[pendingBatchDeleteSessionIds, sessionsById],
|
|
)
|
|
const expanded = isMobile ? true : sidebarOpen
|
|
const closeMobileDrawer = useCallback(() => {
|
|
if (isMobile) onRequestClose?.()
|
|
}, [isMobile, onRequestClose])
|
|
|
|
const applySidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
|
|
setProjectOrder(preferences.projectOrder)
|
|
setPinnedProjectKeys(new Set(preferences.pinnedProjects))
|
|
setHiddenProjectKeys(new Set(preferences.hiddenProjects))
|
|
setProjectOrganizationState(preferences.projectOrganization)
|
|
setProjectSortByState(preferences.projectSortBy)
|
|
}, [])
|
|
|
|
const persistSidebarProjectPreferences = useCallback((preferences: SidebarProjectPreferences) => {
|
|
const normalized = normalizeSidebarProjectPreferences(preferences)
|
|
sidebarPreferenceRevisionRef.current += 1
|
|
writeCachedSidebarProjectPreferences(normalized)
|
|
void desktopUiPreferencesApi.updateSidebarPreferences(normalized).catch(() => undefined)
|
|
}, [])
|
|
|
|
const restoreHiddenProjectForWorkDir = useCallback((workDir: string | null | undefined) => {
|
|
if (!workDir) return
|
|
setHiddenProjectKeys((current) => {
|
|
const next = new Set([...current].filter((projectKey) => !projectPathMatches(projectKey, workDir)))
|
|
if (next.size === current.size) return current
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
|
projectOrder,
|
|
pinnedProjectKeys,
|
|
next,
|
|
projectOrganization,
|
|
projectSortBy,
|
|
))
|
|
return next
|
|
})
|
|
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const startRevision = sidebarPreferenceRevisionRef.current
|
|
|
|
void desktopUiPreferencesApi.getPreferences()
|
|
.then((response) => {
|
|
if (cancelled || startRevision !== sidebarPreferenceRevisionRef.current) return
|
|
|
|
const localPreferences = readCachedSidebarProjectPreferences()
|
|
const serverPreferences = normalizeSidebarProjectPreferences(response.preferences.sidebar)
|
|
const effectivePreferences = response.exists ? serverPreferences : localPreferences
|
|
|
|
applySidebarProjectPreferences(effectivePreferences)
|
|
writeCachedSidebarProjectPreferences(effectivePreferences)
|
|
|
|
if (!response.exists && hasSidebarProjectPreferences(localPreferences)) {
|
|
void desktopUiPreferencesApi.updateSidebarPreferences(localPreferences).catch(() => undefined)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// The sidebar remains usable with the local cache if the server is still booting.
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [applySidebarProjectPreferences])
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
|
e.preventDefault()
|
|
if (isBatchMode) return
|
|
setContextMenu({ id, x: e.clientX, y: e.clientY })
|
|
}, [isBatchMode])
|
|
|
|
const handleProjectDragStart = useCallback((event: React.DragEvent, projectKey: string) => {
|
|
if (isBatchMode) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
suppressProjectClickRef.current = projectKey
|
|
setDraggingProjectKey(projectKey)
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', projectKey)
|
|
}, [isBatchMode])
|
|
|
|
const handleProjectDragOver = useCallback((event: React.DragEvent<HTMLElement>, projectKey: string) => {
|
|
const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain')
|
|
if (!sourceProjectKey || sourceProjectKey === projectKey) return
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'move'
|
|
const position = getProjectDropPosition(event)
|
|
setProjectDropTarget((current) => (
|
|
current?.key === projectKey && current.position === position
|
|
? current
|
|
: { key: projectKey, position }
|
|
))
|
|
}, [draggingProjectKey])
|
|
|
|
const clearProjectDragState = useCallback(() => {
|
|
setDraggingProjectKey(null)
|
|
setProjectDropTarget(null)
|
|
window.setTimeout(() => {
|
|
suppressProjectClickRef.current = null
|
|
}, 0)
|
|
}, [])
|
|
|
|
const handleProjectDrop = useCallback((event: React.DragEvent<HTMLElement>, targetProjectKey: string) => {
|
|
event.preventDefault()
|
|
const sourceProjectKey = draggingProjectKey || event.dataTransfer.getData('text/plain')
|
|
const dropPosition = projectDropTarget?.key === targetProjectKey
|
|
? projectDropTarget.position
|
|
: getProjectDropPosition(event)
|
|
if (!sourceProjectKey || sourceProjectKey === targetProjectKey) {
|
|
clearProjectDragState()
|
|
return
|
|
}
|
|
|
|
const nextOrder = moveProjectKey(
|
|
orderedProjectGroups.map((project) => project.key),
|
|
sourceProjectKey,
|
|
targetProjectKey,
|
|
dropPosition,
|
|
)
|
|
setProjectOrder(nextOrder)
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(nextOrder, pinnedProjectKeys, hiddenProjectKeys, projectOrganization, projectSortBy))
|
|
clearProjectDragState()
|
|
}, [clearProjectDragState, draggingProjectKey, hiddenProjectKeys, orderedProjectGroups, persistSidebarProjectPreferences, pinnedProjectKeys, projectDropTarget, projectOrganization, projectSortBy])
|
|
|
|
const createSessionForWorkDir = useCallback(async (workDir?: string) => {
|
|
try {
|
|
const sessionId = await useSessionStore.getState().createSession(workDir)
|
|
restoreHiddenProjectForWorkDir(workDir)
|
|
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
|
|
useChatStore.getState().connectToSession(sessionId)
|
|
closeMobileDrawer()
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
|
})
|
|
}
|
|
}, [addToast, closeMobileDrawer, restoreHiddenProjectForWorkDir, t])
|
|
|
|
const openProjectHeaderMenu = useCallback((event: React.MouseEvent, type: SidebarHeaderMenuType) => {
|
|
event.stopPropagation()
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
const width = type === 'create' ? 250 : 270
|
|
setProjectContextMenu(null)
|
|
setContextMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
setProjectHeaderMenu({
|
|
type,
|
|
x: Math.max(10, Math.min(rect.right - width, window.innerWidth - width - 10)),
|
|
y: rect.bottom + 8,
|
|
})
|
|
}, [])
|
|
|
|
const openProjectHeaderSubmenu = useCallback((event: React.MouseEvent, type: 'organize' | 'sort') => {
|
|
event.stopPropagation()
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
const width = type === 'sort' ? 230 : 260
|
|
setProjectHeaderSubmenu({
|
|
type,
|
|
x: Math.max(10, Math.min(rect.right + 8, window.innerWidth - width - 10)),
|
|
y: Math.max(10, Math.min(rect.top - 8, window.innerHeight - 170)),
|
|
})
|
|
}, [])
|
|
|
|
const updateProjectOrganization = useCallback((organization: SidebarProjectOrganization) => {
|
|
setProjectHeaderMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
setProjectOrganizationState(organization)
|
|
const nextOrder = organization === 'project' || organization === 'time' ? [] : projectOrder
|
|
if (nextOrder !== projectOrder) setProjectOrder(nextOrder)
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
|
nextOrder,
|
|
pinnedProjectKeys,
|
|
hiddenProjectKeys,
|
|
organization,
|
|
projectSortBy,
|
|
))
|
|
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectSortBy])
|
|
|
|
const updateProjectSortBy = useCallback((sortBy: SidebarProjectSortBy) => {
|
|
setProjectHeaderMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
setProjectSortByState(sortBy)
|
|
const nextOrder: string[] = []
|
|
setProjectOrder(nextOrder)
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
|
nextOrder,
|
|
pinnedProjectKeys,
|
|
hiddenProjectKeys,
|
|
projectOrganization,
|
|
sortBy,
|
|
))
|
|
}, [hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrganization])
|
|
|
|
const createSessionFromExistingFolder = useCallback(async () => {
|
|
setProjectHeaderMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
if (!canUseNativeDialogs) {
|
|
addToast({
|
|
type: 'error',
|
|
message: t('sidebar.chooseProjectFolderUnavailable'),
|
|
})
|
|
return
|
|
}
|
|
try {
|
|
const selected = await getDesktopHost().dialogs.open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: t('sidebar.useExistingFolder'),
|
|
})
|
|
if (typeof selected === 'string' && selected.trim()) {
|
|
await createSessionForWorkDir(selected)
|
|
}
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
|
})
|
|
}
|
|
}, [addToast, createSessionForWorkDir, t])
|
|
|
|
const togglePinnedProject = useCallback((projectKey: string) => {
|
|
setProjectContextMenu(null)
|
|
setPinnedProjectKeys((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(projectKey)) {
|
|
next.delete(projectKey)
|
|
} else {
|
|
next.add(projectKey)
|
|
}
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, next, hiddenProjectKeys, projectOrganization, projectSortBy))
|
|
return next
|
|
})
|
|
}, [hiddenProjectKeys, persistSidebarProjectPreferences, projectOrder, projectOrganization, projectSortBy])
|
|
|
|
const restoreAllHiddenProjects = useCallback(() => {
|
|
setProjectHeaderMenu(null)
|
|
setProjectHeaderSubmenu(null)
|
|
setHiddenProjectKeys((current) => {
|
|
if (current.size === 0) return current
|
|
const next = new Set<string>()
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(
|
|
projectOrder,
|
|
pinnedProjectKeys,
|
|
next,
|
|
projectOrganization,
|
|
projectSortBy,
|
|
))
|
|
return next
|
|
})
|
|
}, [persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy])
|
|
|
|
const toggleHiddenProject = useCallback((project: ProjectGroup) => {
|
|
const wasHidden = hiddenProjectKeys.has(project.key)
|
|
setProjectContextMenu(null)
|
|
setHiddenProjectKeys((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(project.key)) {
|
|
next.delete(project.key)
|
|
} else {
|
|
next.add(project.key)
|
|
}
|
|
persistSidebarProjectPreferences(buildSidebarProjectPreferences(projectOrder, pinnedProjectKeys, next, projectOrganization, projectSortBy))
|
|
return next
|
|
})
|
|
if (!wasHidden) {
|
|
addToast({
|
|
type: 'info',
|
|
message: t('sidebar.projectHidden', { project: project.title }),
|
|
})
|
|
}
|
|
}, [addToast, hiddenProjectKeys, persistSidebarProjectPreferences, pinnedProjectKeys, projectOrder, projectOrganization, projectSortBy, t])
|
|
|
|
const openProjectInFinder = useCallback(async (project: ProjectGroup) => {
|
|
setProjectContextMenu(null)
|
|
try {
|
|
if (!project.workDir) {
|
|
throw new Error(t('sidebar.openInFinderUnavailable'))
|
|
}
|
|
const store = useOpenTargetStore.getState()
|
|
await store.ensureTargets()
|
|
const latest = useOpenTargetStore.getState()
|
|
const target = latest.targets.find((item) => item.id === 'finder')
|
|
?? latest.targets.find((item) => item.kind === 'file_manager')
|
|
if (!target) {
|
|
throw new Error(t('sidebar.openInFinderUnavailable'))
|
|
}
|
|
await latest.openTarget(target.id, project.workDir)
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('sidebar.openInFinderFailed'),
|
|
})
|
|
}
|
|
}, [addToast, t])
|
|
|
|
const handleDelete = useCallback((id: string) => {
|
|
setContextMenu(null)
|
|
setPendingDeleteSessionId(id)
|
|
}, [])
|
|
|
|
const confirmDelete = useCallback(async () => {
|
|
if (!pendingDeleteSessionId) return
|
|
await deleteSession(pendingDeleteSessionId)
|
|
disconnectSession(pendingDeleteSessionId)
|
|
closeTab(pendingDeleteSessionId)
|
|
setPendingDeleteSessionId(null)
|
|
}, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId])
|
|
|
|
const handleBatchSessionClick = useCallback((event: React.MouseEvent, id: string) => {
|
|
if (event.shiftKey && lastSelectedSessionId) {
|
|
const start = filteredSessionIds.indexOf(lastSelectedSessionId)
|
|
const end = filteredSessionIds.indexOf(id)
|
|
if (start >= 0 && end >= 0) {
|
|
const [from, to] = start < end ? [start, end] : [end, start]
|
|
selectSessions(filteredSessionIds.slice(from, to + 1))
|
|
setLastSelectedSessionId(id)
|
|
return
|
|
}
|
|
}
|
|
|
|
toggleSessionSelected(id)
|
|
setLastSelectedSessionId(id)
|
|
}, [filteredSessionIds, lastSelectedSessionId, selectSessions, toggleSessionSelected])
|
|
|
|
const handleExitBatchMode = useCallback(() => {
|
|
exitBatchMode()
|
|
setLastSelectedSessionId(null)
|
|
setPendingBatchDeleteSessionIds(null)
|
|
}, [exitBatchMode])
|
|
|
|
const requestBatchDelete = useCallback((ids: string[]) => {
|
|
if (ids.length === 0) return
|
|
setPendingBatchDeleteSessionIds([...new Set(ids)])
|
|
}, [])
|
|
|
|
const confirmBatchDelete = useCallback(async () => {
|
|
const ids = pendingBatchDeleteSessionIds ?? []
|
|
if (ids.length === 0) return
|
|
|
|
setIsBatchDeleting(true)
|
|
try {
|
|
const result = await deleteSessions(ids)
|
|
for (const sessionId of result.successes) {
|
|
disconnectSession(sessionId)
|
|
closeTab(sessionId)
|
|
}
|
|
|
|
if (result.failures.length > 0) {
|
|
addToast({
|
|
type: 'error',
|
|
message: t('sidebar.batchDeleteFailed', { count: result.failures.length }),
|
|
})
|
|
} else {
|
|
addToast({
|
|
type: 'success',
|
|
message: t('sidebar.batchDeleteSucceeded', { count: result.successes.length }),
|
|
})
|
|
handleExitBatchMode()
|
|
}
|
|
setPendingBatchDeleteSessionIds(null)
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('sidebar.batchDeleteFailed', { count: ids.length }),
|
|
})
|
|
} finally {
|
|
setIsBatchDeleting(false)
|
|
}
|
|
}, [addToast, closeTab, deleteSessions, disconnectSession, handleExitBatchMode, pendingBatchDeleteSessionIds, t])
|
|
|
|
const toggleGroupSelection = useCallback((ids: string[]) => {
|
|
const allSelected = ids.every((id) => selectedSessionIds.has(id))
|
|
if (allSelected) {
|
|
deselectSessions(ids)
|
|
} else {
|
|
selectSessions(ids)
|
|
}
|
|
}, [deselectSessions, selectSessions, selectedSessionIds])
|
|
|
|
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
|
if (suppressProjectClickRef.current === projectKey) {
|
|
suppressProjectClickRef.current = null
|
|
return
|
|
}
|
|
setCollapsedProjectKeys((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(projectKey)) {
|
|
next.delete(projectKey)
|
|
} else {
|
|
next.add(projectKey)
|
|
}
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const toggleProjectSessionExpansion = useCallback((projectKey: string) => {
|
|
setExpandedProjectKeys((current) => {
|
|
const next = new Set(current)
|
|
if (next.has(projectKey)) {
|
|
next.delete(projectKey)
|
|
} else {
|
|
next.add(projectKey)
|
|
}
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const handleStartRename = useCallback((id: string, currentTitle: string) => {
|
|
setContextMenu(null)
|
|
setRenamingId(id)
|
|
setRenameValue(currentTitle)
|
|
}, [])
|
|
|
|
const handleFinishRename = useCallback(async () => {
|
|
if (renamingId && renameValue.trim()) {
|
|
await renameSession(renamingId, renameValue.trim())
|
|
}
|
|
setRenamingId(null)
|
|
setRenameValue('')
|
|
}, [renamingId, renameValue, renameSession])
|
|
|
|
useEffect(() => {
|
|
if (!isBatchMode) return
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const target = event.target as HTMLElement | null
|
|
if (target?.closest('input, textarea, [contenteditable="true"]')) return
|
|
|
|
if (event.key === 'Escape') {
|
|
handleExitBatchMode()
|
|
}
|
|
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
|
|
event.preventDefault()
|
|
selectSessions(filteredSessionIds)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [filteredSessionIds, handleExitBatchMode, isBatchMode, selectSessions])
|
|
|
|
return (
|
|
<aside
|
|
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
|
|
data-state={expanded ? 'open' : 'closed'}
|
|
aria-label="Sidebar"
|
|
>
|
|
<div
|
|
data-testid="sidebar-title-region"
|
|
data-desktop-drag-region
|
|
className={`px-3 pb-2 ${isDesktopRuntime && !isWindows ? 'pt-[44px]' : 'pt-3'}`}
|
|
>
|
|
<div className={`flex ${expanded ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
|
|
<div className={`flex min-w-0 items-center ${expanded ? 'gap-2.5' : 'justify-center'}`}>
|
|
<img src={publicAssetPath('app-icon.png')} alt="" className="h-8 w-8 flex-shrink-0" />
|
|
<span
|
|
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
|
|
style={{ fontFamily: 'var(--font-headline)' }}
|
|
>
|
|
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
|
|
</span>
|
|
</div>
|
|
<div className={`flex items-center ${expanded ? 'gap-1.5' : 'flex-col gap-2'}`}>
|
|
<a
|
|
href="https://github.com/NanmiCoder/cc-haha"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
|
|
title="GitHub"
|
|
tabIndex={expanded ? undefined : -1}
|
|
aria-hidden={!expanded}
|
|
>
|
|
<GitHubIcon />
|
|
</a>
|
|
{isMobile ? (
|
|
<button
|
|
type="button"
|
|
onClick={closeMobileDrawer}
|
|
className="sidebar-toggle-button flex h-11 w-11 items-center justify-center rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]"
|
|
aria-label={t('sidebar.collapse')}
|
|
title={t('sidebar.collapse')}
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={toggleSidebar}
|
|
data-testid={expanded ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
|
|
className={`sidebar-toggle-button ${expanded ? 'sidebar-toggle-button--open h-8 w-8' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
|
|
aria-label={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
|
|
title={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
|
|
>
|
|
<SidebarToggleIcon collapsed={!expanded} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`px-3 pb-3 flex flex-col ${expanded ? 'gap-0.5' : 'items-center gap-2'}`}>
|
|
<NavItem
|
|
active={false}
|
|
collapsed={!expanded}
|
|
label={t('sidebar.newSession')}
|
|
touchFriendly={isMobile}
|
|
onClick={() => {
|
|
const currentTabId = useTabStore.getState().activeTabId
|
|
const currentSession = currentTabId
|
|
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
|
|
: null
|
|
void createSessionForWorkDir(currentSession?.workDir || currentSession?.projectRoot || undefined)
|
|
}}
|
|
icon={<PlusIcon />}
|
|
>
|
|
{t('sidebar.newSession')}
|
|
</NavItem>
|
|
{!isMobile && (
|
|
<NavItem
|
|
active={activeTabId === SCHEDULED_TAB_ID}
|
|
collapsed={!expanded}
|
|
label={t('sidebar.scheduled')}
|
|
touchFriendly={isMobile}
|
|
onClick={() => {
|
|
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
|
|
closeMobileDrawer()
|
|
}}
|
|
icon={<ClockIcon />}
|
|
>
|
|
{t('sidebar.scheduled')}
|
|
</NavItem>
|
|
)}
|
|
</div>
|
|
|
|
{expanded ? (
|
|
<>
|
|
<div
|
|
data-testid="sidebar-search-controls-section"
|
|
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
|
|
style={{ overflow: 'visible' }}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
|
|
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
|
|
<SearchIcon />
|
|
</span>
|
|
<input
|
|
id="sidebar-search"
|
|
type="text"
|
|
placeholder={t('sidebar.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refreshSessionsNow()}
|
|
disabled={isLoading}
|
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[12px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:opacity-65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={t('sidebar.refreshSessions')}
|
|
title={t('sidebar.refreshSessions')}
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} strokeWidth={1.9} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={isBatchMode ? handleExitBatchMode : enterBatchMode}
|
|
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[12px] border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
|
|
isBatchMode
|
|
? 'border-[var(--color-brand)] bg-[var(--color-sidebar-item-active)] text-[var(--color-brand)]'
|
|
: 'border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
aria-label={isBatchMode ? t('sidebar.batchExit') : t('sidebar.batchManage')}
|
|
title={isBatchMode ? t('sidebar.batchExit') : t('sidebar.batchManage')}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">
|
|
{isBatchMode ? 'close' : 'delete_sweep'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="sidebar-session-list-section"
|
|
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
|
|
>
|
|
{isBatchMode && (
|
|
<div className="mx-3 mb-2 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2 shadow-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="min-w-0 text-xs font-medium text-[var(--color-text-primary)]">
|
|
{t('sidebar.batchSelectedCount', { count: selectedCount })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleExitBatchMode}
|
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={t('sidebar.batchExit')}
|
|
title={t('sidebar.batchExit')}
|
|
>
|
|
<span className="material-symbols-outlined text-[17px]">close</span>
|
|
</button>
|
|
</div>
|
|
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (filteredSessionIds.every((id) => selectedSessionIds.has(id))) {
|
|
deselectSessions(filteredSessionIds)
|
|
} else {
|
|
selectSessions(filteredSessionIds)
|
|
}
|
|
}}
|
|
disabled={filteredSessionIds.length === 0}
|
|
className="rounded-md border border-[var(--color-border)] px-2 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
|
>
|
|
{filteredSessionIds.length > 0 && filteredSessionIds.every((id) => selectedSessionIds.has(id))
|
|
? t('sidebar.batchDeselectAll')
|
|
: t('sidebar.batchSelectAll')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => requestBatchDelete([...selectedSessionIds])}
|
|
disabled={selectedCount === 0}
|
|
className="rounded-md bg-[var(--color-error)] px-2 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{t('sidebar.batchDeleteSelected', { count: selectedCount })}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3 pb-20">
|
|
{error && (
|
|
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
|
|
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
|
|
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
|
|
<button
|
|
onClick={() => fetchSessions()}
|
|
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
|
|
>
|
|
{t('common.retry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showInitialLoading ? (
|
|
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
{t('common.loading')}
|
|
</div>
|
|
) : filteredSessions.length === 0 && (
|
|
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
|
|
</div>
|
|
)}
|
|
{orderedProjectGroups.length > 0 && (
|
|
<ProjectHeaderActions
|
|
title={t('sidebar.projects')}
|
|
menuLabel={t('sidebar.projectMenu')}
|
|
createLabel={t('sidebar.newProject')}
|
|
onOpenMenu={(event) => openProjectHeaderMenu(event, 'main')}
|
|
onOpenCreate={(event) => openProjectHeaderMenu(event, 'create')}
|
|
/>
|
|
)}
|
|
{visibleProjectGroups.map((project) => {
|
|
const projectCollapsed = collapsedProjectKeys.has(project.key)
|
|
const sessionsExpanded = expandedProjectKeys.has(project.key)
|
|
const visibleItems = projectCollapsed
|
|
? []
|
|
: getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId)
|
|
const hiddenCount = project.sessions.length - visibleItems.length
|
|
const groupIds = project.sessions.map((session) => session.id)
|
|
const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length
|
|
const hasInternalScroll = sessionsExpanded && project.sessions.length > PROJECT_GROUP_SCROLL_COUNT
|
|
const isProjectDragging = draggingProjectKey === project.key
|
|
const isProjectPinned = pinnedProjectKeys.has(project.key)
|
|
const dropBefore = projectDropTarget?.key === project.key && projectDropTarget.position === 'before'
|
|
const dropAfter = projectDropTarget?.key === project.key && projectDropTarget.position === 'after'
|
|
return (
|
|
<section
|
|
key={project.key}
|
|
data-testid={`sidebar-project-group-${domSafeProjectKey(project.key)}`}
|
|
onDragOver={(event) => handleProjectDragOver(event, project.key)}
|
|
onDrop={(event) => handleProjectDrop(event, project.key)}
|
|
onDragLeave={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
setProjectDropTarget((current) => current?.key === project.key ? null : current)
|
|
}
|
|
}}
|
|
className={`group/project relative mb-3.5 transition-opacity ${isProjectDragging ? 'opacity-50' : ''}`}
|
|
>
|
|
{dropBefore && (
|
|
<div className="pointer-events-none absolute -top-1 left-1 right-1 z-10 h-0.5 rounded-full bg-[var(--color-brand)]" />
|
|
)}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
draggable={!isBatchMode}
|
|
onDragStart={(event) => handleProjectDragStart(event, project.key)}
|
|
onDragEnd={clearProjectDragState}
|
|
onClick={() => toggleProjectCollapsed(project.key)}
|
|
data-state={projectCollapsed ? 'closed' : 'open'}
|
|
className="flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-xl px-1.5 py-2 text-left transition-[background,color] active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-expanded={!projectCollapsed}
|
|
aria-label={t(projectCollapsed ? 'sidebar.expandProject' : 'sidebar.collapseProject', { project: project.title })}
|
|
title={project.subtitle || project.title}
|
|
>
|
|
<span
|
|
data-testid={`sidebar-project-icon-${domSafeProjectKey(project.key)}`}
|
|
data-icon-state={projectCollapsed ? 'closed' : 'open'}
|
|
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center transition-colors ${
|
|
projectCollapsed
|
|
? 'text-[var(--color-text-secondary)]'
|
|
: 'text-[var(--color-text-primary)]'
|
|
}`}
|
|
>
|
|
{projectCollapsed ? (
|
|
<Folder className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />
|
|
) : (
|
|
<FolderOpen className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
<span className={`min-w-0 flex-1 truncate text-[13px] font-semibold leading-5 transition-colors ${
|
|
projectCollapsed
|
|
? 'text-[var(--color-text-secondary)]'
|
|
: 'text-[var(--color-text-primary)]'
|
|
}`}>
|
|
{project.title}
|
|
</span>
|
|
{isProjectPinned && (
|
|
<Pin className="h-3.5 w-3.5 flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
<div className="flex flex-shrink-0 items-center gap-1">
|
|
{isBatchMode && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleGroupSelection(groupIds)}
|
|
className={`rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
|
|
groupSelectedCount > 0
|
|
? 'text-[var(--color-brand)] hover:bg-[var(--color-brand)]/10'
|
|
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)]'
|
|
}`}
|
|
aria-label={t('sidebar.batchSelectGroup', { group: project.title })}
|
|
>
|
|
{groupSelectedCount === groupIds.length
|
|
? t('sidebar.batchDeselectAll')
|
|
: t('sidebar.batchSelectAll')}
|
|
</button>
|
|
)}
|
|
{!isBatchMode && (
|
|
<div className="pointer-events-none flex items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setContextMenu(null)
|
|
setProjectContextMenu({ key: project.key, x: event.clientX, y: event.clientY })
|
|
}}
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={t('sidebar.projectActions', { project: project.title })}
|
|
title={t('sidebar.projectActions', { project: project.title })}
|
|
>
|
|
<MoreHorizontal className="h-[17px] w-[17px]" strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
void createSessionForWorkDir(project.workDir)
|
|
}}
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={t('sidebar.newSessionInProject', { project: project.title })}
|
|
title={t('sidebar.newSessionInProject', { project: project.title })}
|
|
>
|
|
<SquarePen className="h-[16px] w-[16px]" strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!projectCollapsed && (
|
|
<div className="mt-0.5 pl-6">
|
|
<div
|
|
className={hasInternalScroll ? 'max-h-[420px] overflow-y-auto pr-1' : undefined}
|
|
data-testid={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`}
|
|
>
|
|
{visibleItems.map((session) => (
|
|
<div key={session.id} className="relative mb-0.5 last:mb-0">
|
|
{renamingId === session.id ? (
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={handleFinishRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleFinishRename()
|
|
if (e.key === 'Escape') {
|
|
setRenamingId(null)
|
|
setRenameValue('')
|
|
}
|
|
}}
|
|
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
|
|
/>
|
|
) : (
|
|
<button
|
|
onClick={(event) => {
|
|
if (isBatchMode) {
|
|
handleBatchSessionClick(event, session.id)
|
|
return
|
|
}
|
|
useTabStore.getState().openTab(session.id, session.title)
|
|
useChatStore.getState().connectToSession(session.id)
|
|
closeMobileDrawer()
|
|
}}
|
|
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
|
className={`
|
|
group/session w-full rounded-lg px-2.5 ${isMobile ? 'py-3' : 'py-1.5'} text-left text-[13px] transition-[background,filter,color] duration-200
|
|
${selectedSessionIds.has(session.id)
|
|
? 'sidebar-session-row--selected bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
|
|
: session.id === activeTabId
|
|
? 'sidebar-session-row--active bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
|
|
: 'sidebar-session-row--idle text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
|
|
}
|
|
`}
|
|
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{isBatchMode ? (
|
|
<span
|
|
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
|
selectedSessionIds.has(session.id)
|
|
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
|
|
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
|
}`}
|
|
aria-hidden="true"
|
|
>
|
|
{selectedSessionIds.has(session.id) && (
|
|
<span className="material-symbols-outlined text-[12px]">check</span>
|
|
)}
|
|
</span>
|
|
) : null}
|
|
<span className="min-w-0 flex-1 truncate font-medium tracking-normal">{session.title || 'Untitled'}</span>
|
|
{!session.workDirExists && (
|
|
<span
|
|
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
|
|
title={session.workDir ?? ''}
|
|
>
|
|
{t('sidebar.missingDir')}
|
|
</span>
|
|
)}
|
|
<SessionRowMeta
|
|
isRunning={runningSessionIds.has(session.id)}
|
|
isWorktree={isWorktreeSession(session)}
|
|
modifiedAt={session.modifiedAt}
|
|
t={t}
|
|
/>
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{(hiddenCount > 0 || sessionsExpanded) && (
|
|
<div className="mt-2 flex justify-start px-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleProjectSessionExpansion(project.key)}
|
|
className="inline-flex items-center justify-start py-1 text-[13px] font-semibold text-[var(--color-text-tertiary)] opacity-75 transition-[color,opacity] hover:text-[var(--color-text-secondary)] hover:opacity-100 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-expanded={sessionsExpanded}
|
|
>
|
|
{sessionsExpanded
|
|
? t('sidebar.showFewerSessions')
|
|
: t('sidebar.showMoreSessions')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{dropAfter && (
|
|
<div className="pointer-events-none absolute -bottom-1 left-1 right-1 z-10 h-0.5 rounded-full bg-[var(--color-brand)]" />
|
|
)}
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1" aria-hidden="true" />
|
|
)}
|
|
|
|
{!isMobile && (
|
|
<div
|
|
data-testid="sidebar-settings-dock"
|
|
className={`sidebar-settings-dock absolute bottom-0 left-0 right-0 border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}
|
|
>
|
|
<NavItem
|
|
active={activeTabId === SETTINGS_TAB_ID}
|
|
collapsed={!expanded}
|
|
label={t('sidebar.settings')}
|
|
touchFriendly={isMobile}
|
|
onClick={() => {
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
|
|
closeMobileDrawer()
|
|
}}
|
|
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
|
|
>
|
|
{t('sidebar.settings')}
|
|
</NavItem>
|
|
</div>
|
|
)}
|
|
|
|
{contextMenu && (
|
|
<div
|
|
className="fixed z-50 min-w-[140px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] py-1"
|
|
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
const session = sessions.find((s) => s.id === contextMenu.id)
|
|
handleStartRename(contextMenu.id, session?.title || '')
|
|
}}
|
|
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
{t('common.rename')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(contextMenu.id)}
|
|
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-error)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
{t('common.delete')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{projectContextMenu && (() => {
|
|
const project = orderedProjectGroups.find((group) => group.key === projectContextMenu.key)
|
|
if (!project) return null
|
|
const pinned = pinnedProjectKeys.has(project.key)
|
|
const hidden = hiddenProjectKeys.has(project.key)
|
|
return (
|
|
<div
|
|
role="menu"
|
|
className="fixed z-50 min-w-[230px] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
|
|
style={positionProjectMenu(projectContextMenu.x, projectContextMenu.y)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<ProjectMenuItem
|
|
icon={pinned ? <PinOff size={18} aria-hidden="true" /> : <Pin size={18} aria-hidden="true" />}
|
|
onClick={() => togglePinnedProject(project.key)}
|
|
>
|
|
{t(pinned ? 'sidebar.unpinProject' : 'sidebar.pinProject')}
|
|
</ProjectMenuItem>
|
|
<ProjectMenuItem
|
|
icon={<FolderOpen size={18} aria-hidden="true" />}
|
|
onClick={() => void openProjectInFinder(project)}
|
|
>
|
|
{t('sidebar.openInFinder')}
|
|
</ProjectMenuItem>
|
|
<ProjectMenuItem
|
|
icon={hidden ? <RotateCcw size={18} aria-hidden="true" /> : <X size={18} aria-hidden="true" />}
|
|
onClick={() => toggleHiddenProject(project)}
|
|
danger={!hidden}
|
|
>
|
|
{t(hidden ? 'sidebar.restoreProjectToSidebar' : 'sidebar.hideProjectFromSidebar')}
|
|
</ProjectMenuItem>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{projectHeaderMenu && (
|
|
<ProjectHeaderMenu
|
|
type={projectHeaderMenu.type}
|
|
x={projectHeaderMenu.x}
|
|
y={projectHeaderMenu.y}
|
|
organization={projectOrganization}
|
|
sortBy={projectSortBy}
|
|
onOpenSubmenu={openProjectHeaderSubmenu}
|
|
onSetOrganization={updateProjectOrganization}
|
|
onSetSortBy={updateProjectSortBy}
|
|
onCreateBlank={() => void createSessionForWorkDir()}
|
|
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
|
onRestoreHiddenProjects={restoreAllHiddenProjects}
|
|
hiddenProjectCount={hiddenProjectKeys.size}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{projectHeaderSubmenu && (
|
|
<ProjectHeaderMenu
|
|
type={projectHeaderSubmenu.type}
|
|
x={projectHeaderSubmenu.x}
|
|
y={projectHeaderSubmenu.y}
|
|
organization={projectOrganization}
|
|
sortBy={projectSortBy}
|
|
onOpenSubmenu={openProjectHeaderSubmenu}
|
|
onSetOrganization={updateProjectOrganization}
|
|
onSetSortBy={updateProjectSortBy}
|
|
onCreateBlank={() => void createSessionForWorkDir()}
|
|
onUseExistingFolder={() => void createSessionFromExistingFolder()}
|
|
onRestoreHiddenProjects={restoreAllHiddenProjects}
|
|
hiddenProjectCount={hiddenProjectKeys.size}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={pendingDeleteSessionId !== null}
|
|
onClose={() => setPendingDeleteSessionId(null)}
|
|
onConfirm={confirmDelete}
|
|
title={t('common.delete')}
|
|
body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''}
|
|
confirmLabel={t('common.delete')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="danger"
|
|
/>
|
|
<ConfirmDialog
|
|
open={pendingBatchDeleteSessionIds !== null}
|
|
onClose={() => {
|
|
if (!isBatchDeleting) setPendingBatchDeleteSessionIds(null)
|
|
}}
|
|
onConfirm={confirmBatchDelete}
|
|
title={t('common.delete')}
|
|
body={(
|
|
<div className="space-y-3">
|
|
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{t('sidebar.batchDeleteConfirm', { count: pendingBatchDeleteSessionIds?.length ?? 0 })}
|
|
</p>
|
|
<div>
|
|
<div className="mb-1.5 text-xs font-medium text-[var(--color-text-primary)]">
|
|
{t('sidebar.batchDeleteConfirmBody')}
|
|
</div>
|
|
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
|
|
{pendingBatchDeleteSessions.slice(0, 5).map((session) => (
|
|
<li key={session.id} className="truncate text-xs text-[var(--color-text-secondary)]">
|
|
{session.title || 'Untitled'}
|
|
</li>
|
|
))}
|
|
{(pendingBatchDeleteSessionIds?.length ?? 0) > 5 && (
|
|
<li className="text-xs text-[var(--color-text-tertiary)]">
|
|
{t('sidebar.batchDeleteMore', { count: (pendingBatchDeleteSessionIds?.length ?? 0) - 5 })}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
confirmLabel={t('common.delete')}
|
|
cancelLabel={t('common.cancel')}
|
|
confirmVariant="danger"
|
|
loading={isBatchDeleting}
|
|
/>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function useSessionListAutoRefresh(fetchSessions: () => Promise<void>): () => Promise<void> {
|
|
const inFlightRef = useRef<Promise<void> | null>(null)
|
|
const lastStartedAtRef = useRef(0)
|
|
|
|
const refreshSessions = useCallback((force = false) => {
|
|
if (inFlightRef.current) return inFlightRef.current
|
|
|
|
const now = Date.now()
|
|
if (!force && now - lastStartedAtRef.current < SESSION_LIST_FOCUS_REFRESH_MIN_MS) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
lastStartedAtRef.current = now
|
|
const request = Promise.resolve()
|
|
.then(() => fetchSessions())
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
if (inFlightRef.current === request) {
|
|
inFlightRef.current = null
|
|
}
|
|
})
|
|
inFlightRef.current = request
|
|
return request
|
|
}, [fetchSessions])
|
|
|
|
useEffect(() => {
|
|
void refreshSessions(true)
|
|
|
|
const refreshIfVisible = () => {
|
|
if (!isDocumentVisible()) return
|
|
void refreshSessions()
|
|
}
|
|
|
|
window.addEventListener('focus', refreshIfVisible)
|
|
document.addEventListener('visibilitychange', refreshIfVisible)
|
|
const timer = window.setInterval(() => {
|
|
if (!isDocumentVisible()) return
|
|
void refreshSessions(true)
|
|
}, SESSION_LIST_AUTO_REFRESH_MS)
|
|
|
|
return () => {
|
|
window.removeEventListener('focus', refreshIfVisible)
|
|
document.removeEventListener('visibilitychange', refreshIfVisible)
|
|
window.clearInterval(timer)
|
|
}
|
|
}, [refreshSessions])
|
|
|
|
return useCallback(() => refreshSessions(true), [refreshSessions])
|
|
}
|
|
|
|
function isDocumentVisible(): boolean {
|
|
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
}
|
|
|
|
function ProjectHeaderActions({
|
|
title,
|
|
menuLabel,
|
|
createLabel,
|
|
onOpenMenu,
|
|
onOpenCreate,
|
|
}: {
|
|
title: string
|
|
menuLabel: string
|
|
createLabel: string
|
|
onOpenMenu: (event: React.MouseEvent) => void
|
|
onOpenCreate: (event: React.MouseEvent) => void
|
|
}) {
|
|
return (
|
|
<div
|
|
data-testid="sidebar-projects-header"
|
|
className="group/sidebar-projects flex items-center justify-between px-1.5 pb-2 pt-1"
|
|
>
|
|
<div className="text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
|
|
{title}
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover/sidebar-projects:opacity-100 focus-within:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={onOpenMenu}
|
|
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={menuLabel}
|
|
title={menuLabel}
|
|
>
|
|
<MoreHorizontal className="h-[18px] w-[18px]" strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onOpenCreate}
|
|
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
aria-label={createLabel}
|
|
title={createLabel}
|
|
>
|
|
<FolderPlus className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProjectHeaderMenu({
|
|
type,
|
|
x,
|
|
y,
|
|
organization,
|
|
sortBy,
|
|
onOpenSubmenu,
|
|
onSetOrganization,
|
|
onSetSortBy,
|
|
onCreateBlank,
|
|
onUseExistingFolder,
|
|
onRestoreHiddenProjects,
|
|
hiddenProjectCount,
|
|
t,
|
|
}: {
|
|
type: SidebarHeaderMenuType
|
|
x: number
|
|
y: number
|
|
organization: SidebarProjectOrganization
|
|
sortBy: SidebarProjectSortBy
|
|
onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void
|
|
onSetOrganization: (organization: SidebarProjectOrganization) => void
|
|
onSetSortBy: (sortBy: SidebarProjectSortBy) => void
|
|
onCreateBlank: () => void
|
|
onUseExistingFolder: () => void
|
|
onRestoreHiddenProjects: () => void
|
|
hiddenProjectCount: number
|
|
t: ReturnType<typeof useTranslation>
|
|
}) {
|
|
const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270
|
|
const style: React.CSSProperties = { left: x, top: y, width, boxShadow: 'var(--shadow-dropdown)' }
|
|
const className = 'fixed z-50 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]'
|
|
|
|
if (type === 'create') {
|
|
return (
|
|
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
|
<HeaderMenuItem icon={<SquarePen size={18} aria-hidden="true" />} onClick={onCreateBlank}>
|
|
{t('sidebar.newBlankProject')}
|
|
</HeaderMenuItem>
|
|
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} onClick={onUseExistingFolder}>
|
|
{t('sidebar.useExistingFolder')}
|
|
</HeaderMenuItem>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (type === 'organize') {
|
|
return (
|
|
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
|
<HeaderMenuItem icon={<Folder size={18} aria-hidden="true" />} checked={organization === 'project'} onClick={() => onSetOrganization('project')}>
|
|
{t('sidebar.organizeByProject')}
|
|
</HeaderMenuItem>
|
|
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} checked={organization === 'recentProject'} onClick={() => onSetOrganization('recentProject')}>
|
|
{t('sidebar.organizeByRecentProject')}
|
|
</HeaderMenuItem>
|
|
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={organization === 'time'} onClick={() => onSetOrganization('time')}>
|
|
{t('sidebar.organizeByTime')}
|
|
</HeaderMenuItem>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (type === 'sort') {
|
|
return (
|
|
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
|
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={sortBy === 'createdAt'} onClick={() => onSetSortBy('createdAt')}>
|
|
{t('sidebar.sortByCreatedAt')}
|
|
</HeaderMenuItem>
|
|
<HeaderMenuItem icon={<RefreshCw size={18} aria-hidden="true" />} checked={sortBy === 'updatedAt'} onClick={() => onSetSortBy('updatedAt')}>
|
|
{t('sidebar.sortByUpdatedAt')}
|
|
</HeaderMenuItem>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
|
<HeaderMenuItem
|
|
icon={<Folder size={18} aria-hidden="true" />}
|
|
trailing
|
|
onMouseEnter={(event) => onOpenSubmenu(event, 'organize')}
|
|
onClick={(event) => onOpenSubmenu(event, 'organize')}
|
|
>
|
|
{t('sidebar.organizeSidebar')}
|
|
</HeaderMenuItem>
|
|
<HeaderMenuItem
|
|
icon={<Clock size={18} aria-hidden="true" />}
|
|
trailing
|
|
onMouseEnter={(event) => onOpenSubmenu(event, 'sort')}
|
|
onClick={(event) => onOpenSubmenu(event, 'sort')}
|
|
>
|
|
{t('sidebar.sortCondition')}
|
|
</HeaderMenuItem>
|
|
{hiddenProjectCount > 0 && (
|
|
<HeaderMenuItem
|
|
icon={<RotateCcw size={18} aria-hidden="true" />}
|
|
onClick={onRestoreHiddenProjects}
|
|
>
|
|
{t('sidebar.restoreHiddenProjects', { count: hiddenProjectCount })}
|
|
</HeaderMenuItem>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HeaderMenuItem({
|
|
icon,
|
|
children,
|
|
onClick,
|
|
onMouseEnter,
|
|
checked = false,
|
|
trailing = false,
|
|
}: {
|
|
icon: React.ReactNode
|
|
children: React.ReactNode
|
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
|
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
|
checked?: boolean
|
|
trailing?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={onClick}
|
|
onMouseEnter={onMouseEnter}
|
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
|
|
>
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-[var(--color-text-secondary)]">
|
|
{icon}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{children}</span>
|
|
{checked && <Check className="h-[17px] w-[17px] text-[var(--color-text-secondary)]" strokeWidth={2} aria-hidden="true" />}
|
|
{trailing && !checked && (
|
|
<ChevronDown className="-rotate-90 h-[17px] w-[17px] text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] {
|
|
const groupsByKey = new Map<string, SessionListItem[]>()
|
|
for (const session of sessions) {
|
|
const key = getSessionProjectKey(session)
|
|
const items = groupsByKey.get(key) ?? []
|
|
items.push(session)
|
|
groupsByKey.set(key, items)
|
|
}
|
|
|
|
const groups = [...groupsByKey.entries()].map(([key, items]) => {
|
|
const sortedSessions = [...items].sort((a, b) => compareSessionsByTimestamp(a, b, sortBy))
|
|
const newest = sortedSessions[0]
|
|
const projectRoot = newest?.projectRoot || newest?.workDir || key
|
|
return {
|
|
key,
|
|
title: projectTitle(projectRoot),
|
|
subtitle: projectSubtitle(projectRoot, key),
|
|
workDir: projectRoot || newest?.workDir || undefined,
|
|
sessions: sortedSessions,
|
|
}
|
|
})
|
|
|
|
return groups.sort((a, b) => compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy))
|
|
}
|
|
|
|
function applyProjectOrder(
|
|
groups: ProjectGroup[],
|
|
projectOrder: string[],
|
|
pinnedProjectKeys: Set<string>,
|
|
organization: SidebarProjectOrganization,
|
|
sortBy: SidebarProjectSortBy,
|
|
): ProjectGroup[] {
|
|
const orderIndex = new Map(projectOrder.map((key, index) => [key, index]))
|
|
return [...groups].sort((a, b) => {
|
|
const aPinned = pinnedProjectKeys.has(a.key)
|
|
const bPinned = pinnedProjectKeys.has(b.key)
|
|
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
|
if (organization === 'project') return a.title.localeCompare(b.title)
|
|
const aIndex = orderIndex.get(a.key)
|
|
const bIndex = orderIndex.get(b.key)
|
|
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
|
|
if (aIndex !== undefined) return -1
|
|
if (bIndex !== undefined) return 1
|
|
return compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy)
|
|
})
|
|
}
|
|
|
|
function moveProjectKey(
|
|
projectKeys: string[],
|
|
sourceKey: string,
|
|
targetKey: string,
|
|
position: 'before' | 'after',
|
|
): string[] {
|
|
const withoutSource = projectKeys.filter((key) => key !== sourceKey)
|
|
const targetIndex = withoutSource.indexOf(targetKey)
|
|
if (targetIndex < 0) return projectKeys
|
|
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
|
return [
|
|
...withoutSource.slice(0, insertIndex),
|
|
sourceKey,
|
|
...withoutSource.slice(insertIndex),
|
|
]
|
|
}
|
|
|
|
function getProjectDropPosition(event: React.DragEvent<HTMLElement>): 'before' | 'after' {
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after'
|
|
}
|
|
|
|
function readStoredProjectOrder(): string[] {
|
|
if (typeof localStorage === 'undefined') return []
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')
|
|
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function writeStoredProjectOrder(projectOrder: string[]): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
try {
|
|
localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder))
|
|
} catch {
|
|
// Sidebar ordering is a UI preference; ignore storage failures.
|
|
}
|
|
}
|
|
|
|
function readStoredProjectPins(): Set<string> {
|
|
if (typeof localStorage === 'undefined') return new Set()
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')
|
|
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
|
|
} catch {
|
|
return new Set()
|
|
}
|
|
}
|
|
|
|
function writeStoredProjectPins(projectKeys: Set<string>): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
try {
|
|
localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys]))
|
|
} catch {
|
|
// Sidebar pinning is a UI preference; ignore storage failures.
|
|
}
|
|
}
|
|
|
|
function readStoredProjectHidden(): Set<string> {
|
|
if (typeof localStorage === 'undefined') return new Set()
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')
|
|
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
|
|
} catch {
|
|
return new Set()
|
|
}
|
|
}
|
|
|
|
function writeStoredProjectHidden(projectKeys: Set<string>): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
try {
|
|
localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys]))
|
|
} catch {
|
|
// Hidden projects are a local UI preference; ignore storage failures.
|
|
}
|
|
}
|
|
|
|
function readStoredProjectOrganization(): SidebarProjectOrganization {
|
|
if (typeof localStorage === 'undefined') return 'recentProject'
|
|
return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY))
|
|
}
|
|
|
|
function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
try {
|
|
localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization)
|
|
} catch {
|
|
// Sidebar organization is a UI preference; ignore storage failures.
|
|
}
|
|
}
|
|
|
|
function readStoredProjectSortBy(): SidebarProjectSortBy {
|
|
if (typeof localStorage === 'undefined') return 'updatedAt'
|
|
return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY))
|
|
}
|
|
|
|
function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
try {
|
|
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy)
|
|
} catch {
|
|
// Sidebar sorting is a UI preference; ignore storage failures.
|
|
}
|
|
}
|
|
|
|
function buildSidebarProjectPreferences(
|
|
projectOrder: string[],
|
|
pinnedProjectKeys: Set<string>,
|
|
hiddenProjectKeys: Set<string>,
|
|
projectOrganization: SidebarProjectOrganization,
|
|
projectSortBy: SidebarProjectSortBy,
|
|
): SidebarProjectPreferences {
|
|
return normalizeSidebarProjectPreferences({
|
|
projectOrder,
|
|
pinnedProjects: [...pinnedProjectKeys],
|
|
hiddenProjects: [...hiddenProjectKeys],
|
|
projectOrganization,
|
|
projectSortBy,
|
|
})
|
|
}
|
|
|
|
function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
|
|
return {
|
|
projectOrder: readStoredProjectOrder(),
|
|
pinnedProjects: [...readStoredProjectPins()],
|
|
hiddenProjects: [...readStoredProjectHidden()],
|
|
projectOrganization: readStoredProjectOrganization(),
|
|
projectSortBy: readStoredProjectSortBy(),
|
|
}
|
|
}
|
|
|
|
function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void {
|
|
const normalized = normalizeSidebarProjectPreferences(preferences)
|
|
writeStoredProjectOrder(normalized.projectOrder)
|
|
writeStoredProjectPins(new Set(normalized.pinnedProjects))
|
|
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
|
|
writeStoredProjectOrganization(normalized.projectOrganization)
|
|
writeStoredProjectSortBy(normalized.projectSortBy)
|
|
}
|
|
|
|
function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
|
|
return {
|
|
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
|
|
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
|
|
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
|
|
projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization),
|
|
projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy),
|
|
}
|
|
}
|
|
|
|
function normalizeProjectKeyList(values: unknown): string[] {
|
|
if (!Array.isArray(values)) return []
|
|
const seen = new Set<string>()
|
|
const normalized: string[] = []
|
|
|
|
for (const value of values) {
|
|
if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue
|
|
seen.add(value)
|
|
normalized.push(value)
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function normalizeProjectPathForComparison(value: string): string {
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') || value
|
|
return isWindows ? normalized.toLowerCase() : normalized
|
|
}
|
|
|
|
function isDriveRootComparisonPath(value: string): boolean {
|
|
return /^[a-z]:$/i.test(value)
|
|
}
|
|
|
|
function projectPathMatches(projectKey: string, workDir: string): boolean {
|
|
const normalizedProjectKey = normalizeProjectPathForComparison(projectKey)
|
|
const normalizedWorkDir = normalizeProjectPathForComparison(workDir)
|
|
|
|
if (normalizedProjectKey === normalizedWorkDir) return true
|
|
if (isDriveRootComparisonPath(normalizedProjectKey)) return false
|
|
return normalizedWorkDir.startsWith(`${normalizedProjectKey}/`)
|
|
}
|
|
|
|
function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
|
|
return preferences.projectOrder.length > 0
|
|
|| preferences.pinnedProjects.length > 0
|
|
|| preferences.hiddenProjects.length > 0
|
|
|| preferences.projectOrganization !== 'recentProject'
|
|
|| preferences.projectSortBy !== 'updatedAt'
|
|
}
|
|
|
|
function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization {
|
|
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
|
|
}
|
|
|
|
function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy {
|
|
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
|
|
}
|
|
|
|
function getVisibleProjectSessions(
|
|
sessions: SessionListItem[],
|
|
expanded: boolean,
|
|
activeSessionId: string | null,
|
|
): SessionListItem[] {
|
|
if (expanded || sessions.length <= PROJECT_GROUP_VISIBLE_COUNT) return sessions
|
|
|
|
const visible = sessions.slice(0, PROJECT_GROUP_VISIBLE_COUNT)
|
|
if (!activeSessionId || visible.some((session) => session.id === activeSessionId)) return visible
|
|
|
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
|
return activeSession ? [...visible, activeSession] : visible
|
|
}
|
|
|
|
function getSessionProjectKey(session: SessionListItem): string {
|
|
return session.projectRoot || session.workDir || session.projectPath || 'unknown'
|
|
}
|
|
|
|
function compareSessionsByTimestamp(
|
|
a: SessionListItem | undefined,
|
|
b: SessionListItem | undefined,
|
|
sortBy: SidebarProjectSortBy,
|
|
): number {
|
|
return getSessionTimestamp(b, sortBy) - getSessionTimestamp(a, sortBy)
|
|
}
|
|
|
|
function getSessionTimestamp(session: SessionListItem | undefined, sortBy: SidebarProjectSortBy): number {
|
|
const value = sortBy === 'createdAt' ? session?.createdAt : session?.modifiedAt
|
|
const timestamp = new Date(value ?? 0).getTime()
|
|
return Number.isFinite(timestamp) ? timestamp : 0
|
|
}
|
|
|
|
function projectTitle(pathLike: string | null | undefined): string {
|
|
if (!pathLike) return 'Unknown project'
|
|
const normalized = pathLike.replace(/[\\/]+$/, '')
|
|
const segments = normalized.split(/[\\/]/).filter(Boolean)
|
|
const last = segments[segments.length - 1]
|
|
if (last) return last
|
|
return normalized || 'Unknown project'
|
|
}
|
|
|
|
function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null {
|
|
if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey
|
|
return compactProjectPath(projectRoot)
|
|
}
|
|
|
|
function isWorktreeSession(session: SessionListItem): boolean {
|
|
if (!session.workDir) return false
|
|
if (/[\\/]\.claude[\\/]worktrees[\\/]/.test(session.workDir)) return true
|
|
if (!session.projectRoot || session.workDir === session.projectRoot) return false
|
|
return !isSameOrChildPath(session.workDir, session.projectRoot)
|
|
}
|
|
|
|
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
|
const child = normalizePathForCompare(childPath)
|
|
const parent = normalizePathForCompare(parentPath)
|
|
return child === parent || child.startsWith(`${parent}/`)
|
|
}
|
|
|
|
function normalizePathForCompare(pathLike: string): string {
|
|
return pathLike.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
}
|
|
|
|
function compactProjectPath(pathLike: string): string {
|
|
const normalized = normalizePathForCompare(pathLike)
|
|
const segments = normalized.split('/').filter(Boolean)
|
|
if (segments.length <= 3) return normalized
|
|
return `.../${segments.slice(-3, -1).join('/')}`
|
|
}
|
|
|
|
function domSafeProjectKey(projectKey: string): string {
|
|
return projectKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'
|
|
}
|
|
|
|
function positionProjectMenu(clientX: number, clientY: number): React.CSSProperties {
|
|
if (typeof window === 'undefined') return { left: clientX, top: clientY }
|
|
const width = 230
|
|
const height = 280
|
|
return {
|
|
left: Math.max(8, Math.min(clientX, window.innerWidth - width - 8)),
|
|
top: Math.max(8, Math.min(clientY, window.innerHeight - height - 8)),
|
|
}
|
|
}
|
|
|
|
function ProjectMenuItem({
|
|
icon,
|
|
children,
|
|
onClick,
|
|
disabled = false,
|
|
danger = false,
|
|
}: {
|
|
icon: React.ReactNode
|
|
children: React.ReactNode
|
|
onClick?: () => void
|
|
disabled?: boolean
|
|
danger?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={disabled}
|
|
onClick={disabled ? undefined : onClick}
|
|
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] disabled:cursor-default disabled:opacity-45 ${
|
|
danger
|
|
? 'text-[var(--color-error)] enabled:hover:bg-[var(--color-error)]/10'
|
|
: 'text-[var(--color-text-primary)] enabled:hover:bg-[var(--color-surface-hover)]'
|
|
}`}
|
|
>
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-current">
|
|
{icon}
|
|
</span>
|
|
<span className="min-w-0 truncate">{children}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SessionRowMeta({
|
|
isRunning,
|
|
isWorktree,
|
|
modifiedAt,
|
|
t,
|
|
}: {
|
|
isRunning: boolean
|
|
isWorktree: boolean
|
|
modifiedAt: string
|
|
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
|
}) {
|
|
const relativeTime = formatRelativeTime(modifiedAt, t)
|
|
const updatedLabel = t('session.lastUpdated', { time: relativeTime })
|
|
|
|
return (
|
|
<span
|
|
className="ml-auto flex h-5 min-w-[78px] flex-shrink-0 items-center justify-end gap-1.5 text-[10px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
|
|
title={updatedLabel}
|
|
>
|
|
{isRunning && (
|
|
<span
|
|
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center text-[var(--color-success)]"
|
|
aria-label={t('sidebar.sessionRunning')}
|
|
title={t('sidebar.sessionRunning')}
|
|
>
|
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" strokeWidth={2.2} aria-hidden="true" />
|
|
</span>
|
|
)}
|
|
{isWorktree && (
|
|
<span
|
|
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)]"
|
|
title={t('sidebar.worktree')}
|
|
>
|
|
<GitBranch className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
|
<span className="sr-only">{t('sidebar.worktree')}</span>
|
|
</span>
|
|
)}
|
|
<span className="inline-flex min-w-[42px] flex-shrink-0 items-center justify-end">
|
|
<span>{relativeTime}</span>
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function NavItem({
|
|
active,
|
|
collapsed,
|
|
label,
|
|
touchFriendly,
|
|
onClick,
|
|
icon,
|
|
children,
|
|
}: {
|
|
active: boolean
|
|
collapsed: boolean
|
|
label: string
|
|
touchFriendly?: boolean
|
|
onClick: () => void
|
|
icon: React.ReactNode
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
title={collapsed ? label : undefined}
|
|
className={`
|
|
flex items-center transition-colors duration-200
|
|
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full gap-2.5 rounded-[12px] px-3 ${touchFriendly ? 'py-3' : 'py-2.5'} text-sm`}
|
|
${active
|
|
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
|
|
}
|
|
`}
|
|
>
|
|
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
|
{icon}
|
|
</span>
|
|
<span className={`sidebar-copy ${collapsed ? 'sidebar-copy--hidden' : 'sidebar-copy--visible'}`}>
|
|
{children}
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function formatRelativeTime(
|
|
dateStr: string,
|
|
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
const date = new Date(dateStr)
|
|
const timestamp = date.getTime()
|
|
if (!Number.isFinite(timestamp)) return ''
|
|
|
|
const diff = Date.now() - timestamp
|
|
const min = Math.floor(diff / 60000)
|
|
if (min < 1) return t('session.timeJustNow')
|
|
if (min < 60) return t('session.timeMinutes', { n: min })
|
|
const hr = Math.floor(min / 60)
|
|
if (hr < 24) return t('session.timeHours', { n: hr })
|
|
const day = Math.floor(hr / 24)
|
|
if (day < 30) return t('session.timeDays', { n: day })
|
|
return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
|
|
}
|
|
|
|
function GitHubIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function PlusIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function ClockIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function SearchIcon() {
|
|
return (
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) {
|
|
return (
|
|
<svg
|
|
width={collapsed ? 16 : 14}
|
|
height={collapsed ? 16 : 14}
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
className={`sidebar-toggle-icon ${collapsed ? 'sidebar-toggle-icon--collapsed' : 'sidebar-toggle-icon--open'}`}
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d={collapsed ? 'M5 3 9 7l-4 4' : 'M9 3 5 7l4 4'}
|
|
className="sidebar-toggle-chevron"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|