程序员阿江(Relakkes) 03112f7152 Make desktop sidebar collapse recoverable without stealing focus
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
2026-04-20 18:14:01 +08:00

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>
)
}