程序员阿江(Relakkes) 7d1e2a2ee4 Reduce sidebar control clutter in session navigation
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
2026-04-22 17:00:53 +08:00

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