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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 17:00:53 +08:00
parent c4772a097e
commit 7d1e2a2ee4
3 changed files with 110 additions and 43 deletions

View File

@ -23,7 +23,7 @@ let cachedProjects: RecentProject[] | null = null
let cacheTimestamp = 0
const CACHE_TTL = 30_000
export function ProjectFilter() {
export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | 'embedded' }) {
const t = useTranslation()
const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore()
const [open, setOpen] = useState(false)
@ -134,6 +134,7 @@ export function ProjectFilter() {
: selectedProjects.length === 1
? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other'))
: `${selectedProjects.length} projects`
const triggerLabel = isAllSelected ? t('sidebar.allProjects') : label
const toggleProject = (projectPath: string) => {
if (isAllSelected) {
@ -160,19 +161,45 @@ export function ProjectFilter() {
type="button"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
className="flex items-center gap-1.5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm font-medium text-[var(--color-text-primary)] shadow-[0_1px_0_rgba(255,255,255,0.6)] transition-colors hover:bg-[var(--color-surface-hover)]"
aria-label={triggerLabel}
title={triggerLabel}
className={
variant === 'embedded'
? `inline-flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors duration-200 ${
isAllSelected
? 'border-transparent text-[var(--color-text-tertiary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)]'
: 'border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] hover:bg-[var(--color-sidebar-item-hover)]'
}`
: 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-[10px] border border-[var(--color-sidebar-filter-border)] bg-[var(--color-sidebar-filter-bg)] px-2 text-left text-[14px] text-[var(--color-text-primary)] transition-colors duration-200 hover:bg-[var(--color-sidebar-item-hover)]'
}
>
<span className="truncate max-w-[180px]">{label}</span>
<ChevronIcon open={open} />
{variant === 'embedded' ? (
<span className="relative flex items-center justify-center">
<FolderIcon className="h-[14px] w-[14px]" />
{!isAllSelected && (
<span className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full bg-[var(--color-brand)]" />
)}
</span>
) : (
<>
<FolderIcon className="h-[14px] w-[14px] text-[var(--color-text-secondary)]" />
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold tracking-tight">{label}</span>
</span>
<span className="flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors">
<ChevronIcon open={open} />
</span>
</>
)}
</button>
{open && dropdownPos && createPortal(
<div
ref={dropdownRef}
className="w-[380px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
style={{
position: 'fixed',
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(380, window.innerWidth - 32) - 16),
left: Math.min(dropdownPos.left, window.innerWidth - Math.min(360, window.innerWidth - 32) - 16),
...(dropdownPos.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - dropdownPos.top }),
@ -184,9 +211,13 @@ export function ProjectFilter() {
<button
type="button"
onClick={selectAll}
className="flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${
isAllSelected
? 'bg-[var(--color-sidebar-item-active)]'
: 'hover:bg-[var(--color-sidebar-item-hover)]'
}`}
>
<FolderIcon />
<FolderIcon className="text-[var(--color-text-secondary)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{t('sidebar.allProjects')}</div>
</div>
@ -207,11 +238,13 @@ export function ProjectFilter() {
key={option.projectPath}
type="button"
onClick={() => toggleProject(option.projectPath)}
className={`flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
checked ? 'bg-[var(--color-surface-selected)]' : ''
className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${
checked
? 'bg-[var(--color-sidebar-item-active)]'
: 'hover:bg-[var(--color-sidebar-item-hover)]'
}`}
>
{option.isGit ? <GitBranchIcon /> : <FolderIcon />}
{option.isGit ? <GitBranchIcon className="text-[var(--color-text-secondary)]" /> : <FolderIcon />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{option.title}</div>
{option.subtitle && (
@ -277,7 +310,7 @@ function ChevronIcon({ open }: { open: boolean }) {
)
}
function FolderIcon() {
function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
return (
<svg
width="20"
@ -288,14 +321,14 @@ function FolderIcon() {
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
className={`flex-shrink-0 ${className}`}
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
)
}
function GitBranchIcon() {
function GitBranchIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) {
return (
<svg
width="20"
@ -306,7 +339,7 @@ function GitBranchIcon() {
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-[var(--color-text-secondary)]"
className={`flex-shrink-0 ${className}`}
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />

View File

@ -125,7 +125,7 @@ export function 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 rounded-lg flex-shrink-0" />
<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)' }}
@ -200,25 +200,26 @@ export function Sidebar() {
<>
<div
data-testid="sidebar-project-filter-section"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-1"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
style={{ overflow: 'visible' }}
>
<div className="flex items-center justify-between">
<ProjectFilter />
<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 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
data-testid="sidebar-session-list-section"
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
@ -246,7 +247,7 @@ export function Sidebar() {
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)]">
<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) => (
@ -274,22 +275,22 @@ export function Sidebar() {
}}
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
group w-full rounded-[12px] px-3 py-2 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)]'
? '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">
<span className="flex items-center gap-2.5">
<span
className="h-1 w-1 flex-shrink-0 rounded-full"
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">{session.title || 'Untitled'}</span>
<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)]"
@ -399,11 +400,11 @@ function NavItem({
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'}
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-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)]'
? '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)]'
}
`}
>
@ -455,6 +456,15 @@ function ClockIcon() {
)
}
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

View File

@ -159,6 +159,15 @@
--color-surface-sidebar: var(--color-surface-container-low);
--color-surface-hover: var(--color-surface-container-high);
--color-surface-selected: var(--color-surface-container);
--color-sidebar-filter-bg: transparent;
--color-sidebar-filter-border: rgba(218, 193, 186, 0.92);
--color-sidebar-filter-icon-bg: transparent;
--color-sidebar-search-bg: rgba(255, 255, 255, 0.78);
--color-sidebar-search-border: rgba(218, 193, 186, 0.88);
--color-sidebar-item-hover: rgba(239, 238, 234, 0.82);
--color-sidebar-item-active: rgba(239, 238, 234, 0.94);
--color-sidebar-item-active-border: rgba(143, 72, 47, 0.12);
--sidebar-panel-bg-image: none;
/* Border aliases */
--color-border: var(--color-outline-variant);
@ -179,6 +188,7 @@
/* Shadows */
--shadow-dropdown: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08);
--shadow-sidebar-filter: none;
--shadow-focus-ring: 0 0 0 1px rgba(143, 72, 47, 0.16);
--shadow-error-ring: 0 0 0 1px rgba(186, 26, 26, 0.18);
--shadow-button-primary: 0 8px 24px rgba(143, 72, 47, 0.18);
@ -290,9 +300,20 @@
--color-success: #7EDB8B;
--color-warning: #F7C46C;
--color-surface-sidebar: var(--color-surface-container-low);
--color-surface-sidebar: #171615;
--color-surface-hover: var(--color-surface-container-highest);
--color-surface-selected: var(--color-surface-container);
--color-sidebar-filter-bg: rgba(255, 255, 255, 0.01);
--color-sidebar-filter-border: rgba(255, 255, 255, 0.08);
--color-sidebar-filter-icon-bg: transparent;
--color-sidebar-search-bg: rgba(255, 255, 255, 0.03);
--color-sidebar-search-border: rgba(255, 255, 255, 0.06);
--color-sidebar-item-hover: rgba(255, 255, 255, 0.04);
--color-sidebar-item-active: rgba(255, 255, 255, 0.06);
--color-sidebar-item-active-border: rgba(255, 181, 159, 0.14);
--sidebar-panel-bg-image:
radial-gradient(circle at top left, rgba(255, 181, 159, 0.045), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
--color-border: rgba(90, 65, 56, 0.18);
--color-border-focus: rgba(255, 181, 159, 0.55);
@ -306,7 +327,8 @@
--color-brand: var(--color-primary);
--color-text-accent: var(--color-tertiary);
--shadow-dropdown: 0 12px 32px rgba(0, 0, 0, 0.4);
--shadow-dropdown: 0 16px 40px rgba(0, 0, 0, 0.42);
--shadow-sidebar-filter: none;
--shadow-focus-ring: 0 0 0 1px rgba(255, 181, 159, 0.32);
--shadow-error-ring: 0 0 0 1px rgba(255, 180, 171, 0.26);
--shadow-button-primary: 0 12px 24px rgba(255, 110, 64, 0.18);
@ -449,6 +471,8 @@ button, input, textarea, select, a, [role="button"] {
.sidebar-panel {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background-image: var(--sidebar-panel-bg-image);
background-repeat: no-repeat;
transition:
background-color var(--motion-sidebar-duration) var(--motion-sidebar-easing),
border-color var(--motion-sidebar-duration) var(--motion-sidebar-easing);