mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
The desktop shell now owns sidebar collapse state, the collapsed rail keeps a visible recovery affordance, and keyboard search expands the sidebar before focusing. The UI work also smooths the width transition so the main chat area can take over without the previous abrupt cutover. Constraint: The desktop app must keep a recovery path visible in narrow layouts on macOS and Windows Constraint: Existing sidebar behavior needed regression coverage before further UI work Rejected: Fully hide the sidebar at 0 width | recovery affordance became too fragile for this iteration Rejected: Keep boxed restore controls in the collapsed rail | visually heavy and easy to misplace Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep the collapsed rail width and the inner sidebar panel width in sync or expand controls will be clipped again Tested: cd desktop && bun run lint Tested: cd desktop && bun run test Sidebar.test.tsx Not-tested: Full desktop manual pass across every app screen after the final icon/layout tweaks
468 lines
20 KiB
TypeScript
468 lines
20 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.jpg" alt="" className="h-8 w-8 rounded-lg 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 className="sidebar-section sidebar-section--visible flex-none px-3 pb-1">
|
|
<div className="flex items-center justify-between">
|
|
<ProjectFilter />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sidebar-section sidebar-section--visible flex-none px-3 pb-2">
|
|
<input
|
|
id="sidebar-search"
|
|
type="text"
|
|
placeholder={t('sidebar.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full h-8 px-2.5 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="sidebar-section sidebar-section--visible flex-1 min-h-0">
|
|
<div className="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-3 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-[var(--radius-md)] py-1.5 pl-4 pr-3 text-left text-sm transition-colors duration-200
|
|
${session.id === activeTabId
|
|
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
|
}
|
|
`}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<span
|
|
className="h-1 w-1 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">{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 rounded-[var(--radius-md)] transition-all duration-200
|
|
${collapsed ? 'h-10 w-10 justify-center px-0 py-0' : 'w-full gap-2.5 px-3 py-2 text-sm'}
|
|
${active
|
|
? 'bg-[var(--color-surface-selected)] font-medium text-[var(--color-text-primary)] shadow-[0_8px_24px_rgba(15,23,42,0.08)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-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 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>
|
|
)
|
|
}
|