mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The project filter and session search were competing for the same narrow vertical lane in the desktop sidebar. This change merges them into a single search surface, keeps project scoping available through an embedded trigger, and trims the surrounding control chrome so the sidebar reads like one cohesive tool instead of stacked widgets. Constraint: Sidebar filtering behavior and existing project dropdown logic had to remain intact Constraint: This commit must exclude unrelated in-progress desktop and server changes in the worktree Rejected: Keep separate project and search controls with smaller spacing | still wastes vertical space and keeps the visual split Rejected: Move project filtering into a second modal flow | adds friction to a frequent navigation task Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep project scoping embedded in the search lane unless session discovery changes materially Tested: bun run test src/components/layout/ProjectFilter.test.tsx src/components/layout/Sidebar.test.tsx src/__tests__/pages.test.tsx; bun run lint; bun run build Not-tested: Manual dark-theme screenshot pass after the final integrated layout change
485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { ProjectFilter } from './ProjectFilter'
|
|
import type { SessionListItem } from '../../types/session'
|
|
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
|
|
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
|
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
|
|
|
type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older'
|
|
|
|
const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last30days', 'older']
|
|
|
|
export function Sidebar() {
|
|
const sessions = useSessionStore((s) => s.sessions)
|
|
const selectedProjects = useSessionStore((s) => s.selectedProjects)
|
|
const error = useSessionStore((s) => s.error)
|
|
const fetchSessions = useSessionStore((s) => s.fetchSessions)
|
|
const deleteSession = useSessionStore((s) => s.deleteSession)
|
|
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 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 [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
|
|
useEffect(() => {
|
|
fetchSessions()
|
|
}, [fetchSessions])
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu || sidebarOpen) return
|
|
setContextMenu(null)
|
|
}, [contextMenu, sidebarOpen])
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return
|
|
const close = () => setContextMenu(null)
|
|
document.addEventListener('click', close)
|
|
return () => document.removeEventListener('click', close)
|
|
}, [contextMenu])
|
|
|
|
const filteredSessions = useMemo(() => {
|
|
let result = sessions
|
|
if (selectedProjects.length > 0) {
|
|
result = result.filter((s) => selectedProjects.includes(s.projectPath))
|
|
}
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase()
|
|
result = result.filter((s) => s.title.toLowerCase().includes(q))
|
|
}
|
|
return result
|
|
}, [sessions, selectedProjects, searchQuery])
|
|
|
|
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
|
e.preventDefault()
|
|
setContextMenu({ id, x: e.clientX, y: e.clientY })
|
|
}, [])
|
|
|
|
const handleDelete = useCallback(async (id: string) => {
|
|
setContextMenu(null)
|
|
await deleteSession(id)
|
|
disconnectSession(id)
|
|
closeTab(id)
|
|
}, [closeTab, deleteSession, disconnectSession])
|
|
|
|
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])
|
|
|
|
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!isTauri) return
|
|
import(/* @vite-ignore */ '@tauri-apps/api/window')
|
|
.then(({ getCurrentWindow }) => {
|
|
const win = getCurrentWindow()
|
|
startDraggingRef.current = () => win.startDragging()
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
const handleSidebarDrag = useCallback((e: React.MouseEvent) => {
|
|
if ((e.target as HTMLElement).closest('button, input, textarea, select, a, [role="button"]')) return
|
|
startDraggingRef.current?.()
|
|
}, [])
|
|
|
|
const t = useTranslation()
|
|
|
|
const timeGroupLabels: Record<TimeGroup, string> = {
|
|
today: t('sidebar.timeGroup.today'),
|
|
yesterday: t('sidebar.timeGroup.yesterday'),
|
|
last7days: t('sidebar.timeGroup.last7days'),
|
|
last30days: t('sidebar.timeGroup.last30days'),
|
|
older: t('sidebar.timeGroup.older'),
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
onMouseDown={handleSidebarDrag}
|
|
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
|
|
data-state={sidebarOpen ? 'open' : 'closed'}
|
|
aria-label="Sidebar"
|
|
>
|
|
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
|
|
<div className={`flex ${sidebarOpen ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
|
|
<div className={`flex min-w-0 items-center ${sidebarOpen ? 'gap-2.5' : 'justify-center'}`}>
|
|
<img src="/app-icon.png" alt="" className="h-8 w-8 flex-shrink-0" />
|
|
<span
|
|
className={`sidebar-copy ${sidebarOpen ? '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 ${sidebarOpen ? 'gap-1.5' : 'flex-col gap-2'}`}>
|
|
<a
|
|
href="https://github.com/NanmiCoder/cc-haha"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`sidebar-copy ${sidebarOpen ? '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={sidebarOpen ? undefined : -1}
|
|
aria-hidden={!sidebarOpen}
|
|
>
|
|
<GitHubIcon />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={toggleSidebar}
|
|
data-testid={sidebarOpen ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
|
|
className={`sidebar-toggle-button ${sidebarOpen ? '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={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
|
|
title={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
|
|
>
|
|
<SidebarToggleIcon collapsed={!sidebarOpen} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`px-3 pb-3 flex flex-col ${sidebarOpen ? 'gap-0.5' : 'items-center gap-2'}`}>
|
|
<NavItem
|
|
active={false}
|
|
collapsed={!sidebarOpen}
|
|
label={t('sidebar.newSession')}
|
|
onClick={async () => {
|
|
try {
|
|
const currentTabId = useTabStore.getState().activeTabId
|
|
const currentSession = currentTabId
|
|
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
|
|
: null
|
|
const workDir = currentSession?.workDir || undefined
|
|
const sessionId = await useSessionStore.getState().createSession(workDir)
|
|
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
|
|
useChatStore.getState().connectToSession(sessionId)
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
|
})
|
|
}
|
|
}}
|
|
icon={<PlusIcon />}
|
|
>
|
|
{t('sidebar.newSession')}
|
|
</NavItem>
|
|
<NavItem
|
|
active={activeTabId === SCHEDULED_TAB_ID}
|
|
collapsed={!sidebarOpen}
|
|
label={t('sidebar.scheduled')}
|
|
onClick={() => useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')}
|
|
icon={<ClockIcon />}
|
|
>
|
|
{t('sidebar.scheduled')}
|
|
</NavItem>
|
|
</div>
|
|
|
|
{sidebarOpen ? (
|
|
<>
|
|
<div
|
|
data-testid="sidebar-project-filter-section"
|
|
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
|
|
style={{ overflow: 'visible' }}
|
|
>
|
|
<div className="flex h-9 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
|
|
<ProjectFilter variant="embedded" />
|
|
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
|
|
<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>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="sidebar-session-list-section"
|
|
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
|
|
>
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-3">
|
|
{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>
|
|
)}
|
|
{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>
|
|
)}
|
|
{TIME_GROUP_ORDER.map((group) => {
|
|
const items = timeGroups.get(group)
|
|
if (!items || items.length === 0) return null
|
|
return (
|
|
<div key={group} className="mb-1">
|
|
<div className="px-2 pb-1 pt-4 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
|
|
{timeGroupLabels[group]}
|
|
</div>
|
|
{items.map((session) => (
|
|
<div key={session.id} className="relative">
|
|
{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="ml-1 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={() => {
|
|
useTabStore.getState().openTab(session.id, session.title)
|
|
useChatStore.getState().connectToSession(session.id)
|
|
}}
|
|
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
|
className={`
|
|
group w-full rounded-[12px] px-3 py-2 text-left text-sm transition-colors duration-200
|
|
${session.id === activeTabId
|
|
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]'
|
|
}
|
|
`}
|
|
>
|
|
<span className="flex items-center gap-2.5">
|
|
<span
|
|
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
|
style={{
|
|
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
|
|
opacity: session.id === activeTabId ? 1 : 0.5,
|
|
}}
|
|
/>
|
|
<span className="flex-1 truncate font-medium tracking-[-0.01em]">{session.title || 'Untitled'}</span>
|
|
{!session.workDirExists && (
|
|
<span
|
|
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
|
|
title={session.workDir ?? ''}
|
|
>
|
|
{t('sidebar.missingDir')}
|
|
</span>
|
|
)}
|
|
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover:opacity-100">
|
|
{formatRelativeTime(session.modifiedAt)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1" aria-hidden="true" />
|
|
)}
|
|
|
|
<div className={`border-t border-[var(--color-border)] p-3 ${sidebarOpen ? '' : 'flex justify-center'}`}>
|
|
<NavItem
|
|
active={activeTabId === SETTINGS_TAB_ID}
|
|
collapsed={!sidebarOpen}
|
|
label={t('sidebar.settings')}
|
|
onClick={() => useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')}
|
|
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
|
|
>
|
|
{t('sidebar.settings')}
|
|
</NavItem>
|
|
</div>
|
|
|
|
{contextMenu && sidebarOpen && (
|
|
<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>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListItem[]> {
|
|
const groups = new Map<TimeGroup, SessionListItem[]>()
|
|
const now = new Date()
|
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
const startOfYesterday = startOfToday - 86400000
|
|
const sevenDaysAgo = startOfToday - 7 * 86400000
|
|
const thirtyDaysAgo = startOfToday - 30 * 86400000
|
|
|
|
for (const session of sessions) {
|
|
const ts = new Date(session.modifiedAt).getTime()
|
|
let group: TimeGroup
|
|
if (ts >= startOfToday) group = 'today'
|
|
else if (ts >= startOfYesterday) group = 'yesterday'
|
|
else if (ts >= sevenDaysAgo) group = 'last7days'
|
|
else if (ts >= thirtyDaysAgo) group = 'last30days'
|
|
else group = 'older'
|
|
|
|
if (!groups.has(group)) groups.set(group, [])
|
|
groups.get(group)!.push(session)
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
function NavItem({
|
|
active,
|
|
collapsed,
|
|
label,
|
|
onClick,
|
|
icon,
|
|
children,
|
|
}: {
|
|
active: boolean
|
|
collapsed: boolean
|
|
label: string
|
|
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 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): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const min = Math.floor(diff / 60000)
|
|
if (min < 1) return 'now'
|
|
if (min < 60) return `${min}m`
|
|
const hr = Math.floor(min / 60)
|
|
if (hr < 24) return `${hr}h`
|
|
const day = Math.floor(hr / 24)
|
|
if (day < 30) return `${day}d`
|
|
return `${Math.floor(day / 30)}mo`
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|