mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
e20ccf8093
commit
03112f7152
@ -14,6 +14,7 @@ import { useTranslation } from '../../i18n'
|
||||
|
||||
export function AppShell() {
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [startupError, setStartupError] = useState<string | null>(null)
|
||||
const t = useTranslation()
|
||||
@ -94,9 +95,19 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main id="content-area" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-screen flex overflow-hidden bg-[var(--color-surface)]">
|
||||
<div
|
||||
data-testid="sidebar-shell"
|
||||
data-state={sidebarOpen ? 'open' : 'closed'}
|
||||
className="sidebar-shell"
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<main
|
||||
id="content-area"
|
||||
data-sidebar-state={sidebarOpen ? 'open' : 'closed'}
|
||||
className="min-w-0 flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabBar />
|
||||
<ContentRouter />
|
||||
</main>
|
||||
|
||||
@ -25,6 +25,8 @@ vi.mock('../../i18n', () => ({
|
||||
'sidebar.timeGroup.last30days': 'Last 30 Days',
|
||||
'sidebar.timeGroup.older': 'Older',
|
||||
'sidebar.missingDir': 'Missing',
|
||||
'sidebar.collapse': 'Collapse sidebar',
|
||||
'sidebar.expand': 'Expand sidebar',
|
||||
}
|
||||
|
||||
return translations[key] ?? key
|
||||
@ -70,6 +72,7 @@ describe('Sidebar', () => {
|
||||
disconnectSession,
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useUIStore.setState({
|
||||
sidebarOpen: true,
|
||||
addToast,
|
||||
} as Partial<ReturnType<typeof useUIStore.getState>>)
|
||||
})
|
||||
@ -155,4 +158,25 @@ describe('Sidebar', () => {
|
||||
expect(useTabStore.getState().tabs).toEqual([])
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses into an icon rail and expands back', async () => {
|
||||
render(<Sidebar />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
})
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(false)
|
||||
expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed')
|
||||
expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' }))
|
||||
})
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true)
|
||||
expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open')
|
||||
})
|
||||
})
|
||||
|
||||
@ -22,6 +22,8 @@ export function Sidebar() {
|
||||
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)
|
||||
@ -34,7 +36,11 @@ export function Sidebar() {
|
||||
fetchSessions()
|
||||
}, [fetchSessions])
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
if (!contextMenu || sidebarOpen) return
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const close = () => setContextMenu(null)
|
||||
@ -42,7 +48,6 @@ export function Sidebar() {
|
||||
return () => document.removeEventListener('click', close)
|
||||
}, [contextMenu])
|
||||
|
||||
// Filter by selected projects, then by search query
|
||||
const filteredSessions = useMemo(() => {
|
||||
let result = sessions
|
||||
if (selectedProjects.length > 0) {
|
||||
@ -55,7 +60,6 @@ export function Sidebar() {
|
||||
return result
|
||||
}, [sessions, selectedProjects, searchQuery])
|
||||
|
||||
// Group by time
|
||||
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
||||
@ -103,7 +107,7 @@ export function Sidebar() {
|
||||
|
||||
const t = useTranslation()
|
||||
|
||||
const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
|
||||
const timeGroupLabels: Record<TimeGroup, string> = {
|
||||
today: t('sidebar.timeGroup.today'),
|
||||
yesterday: t('sidebar.timeGroup.yesterday'),
|
||||
last7days: t('sidebar.timeGroup.last7days'),
|
||||
@ -112,32 +116,56 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside onMouseDown={handleSidebarDrag} className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
|
||||
{/* Brand logo — extra top padding in desktop to clear macOS traffic lights (not needed on Windows) */}
|
||||
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
|
||||
<span className="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>
|
||||
<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>
|
||||
<a
|
||||
href="https://github.com/NanmiCoder/cc-haha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="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"
|
||||
>
|
||||
<GitHubIcon />
|
||||
</a>
|
||||
</div>
|
||||
{/* Navigation */}
|
||||
<div className="px-3 pb-3 flex flex-col gap-0.5">
|
||||
|
||||
<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 {
|
||||
// Use current active session's workDir as default for new session
|
||||
const currentTabId = useTabStore.getState().activeTabId
|
||||
const currentSession = currentTabId
|
||||
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
|
||||
@ -149,8 +177,7 @@ export function Sidebar() {
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message:
|
||||
error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
||||
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
|
||||
})
|
||||
}
|
||||
}}
|
||||
@ -160,6 +187,8 @@ export function Sidebar() {
|
||||
</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 />}
|
||||
>
|
||||
@ -167,108 +196,124 @@ export function Sidebar() {
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{/* Project filter */}
|
||||
<div className="px-3 pb-1 flex items-center justify-between">
|
||||
<ProjectFilter />
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="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-7 px-2 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 focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session list — grouped by time */}
|
||||
<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-xs text-[var(--color-text-tertiary)] text-center">
|
||||
{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 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide">
|
||||
{TIME_GROUP_LABELS[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="w-full px-3 py-2 text-sm rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] text-[var(--color-text-primary)] outline-none ml-1"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
useTabStore.getState().openTab(session.id, session.title)
|
||||
useChatStore.getState().connectToSession(session.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
||||
className={`
|
||||
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
|
||||
${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="w-1 h-1 rounded-full flex-shrink-0" style={{
|
||||
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
|
||||
opacity: session.id === activeTabId ? 1 : 0.5,
|
||||
}} />
|
||||
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
|
||||
{!session.workDirExists && (
|
||||
<span
|
||||
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
|
||||
title={session.workDir ?? ''}
|
||||
>
|
||||
{t('sidebar.missingDir')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{formatRelativeTime(session.modifiedAt)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Settings button at bottom */}
|
||||
<div className="p-3 border-t border-[var(--color-border)]">
|
||||
<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>}
|
||||
>
|
||||
@ -276,24 +321,23 @@ export function Sidebar() {
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
{contextMenu && sidebarOpen && (
|
||||
<div
|
||||
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[140px]"
|
||||
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)
|
||||
const session = sessions.find((s) => s.id === contextMenu.id)
|
||||
handleStartRename(contextMenu.id, session?.title || '')
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
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-xs text-left text-[var(--color-error)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
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>
|
||||
@ -327,20 +371,41 @@ function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListIte
|
||||
return groups
|
||||
}
|
||||
|
||||
function NavItem({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
|
||||
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={`
|
||||
w-full flex items-center gap-2.5 px-3 py-2 text-sm rounded-[var(--radius-md)] transition-colors duration-200
|
||||
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)] text-[var(--color-text-primary)] font-medium'
|
||||
? '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)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -382,3 +447,21 @@ function ClockIcon() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useUIStore } from '../stores/uiStore'
|
||||
export function useKeyboardShortcuts() {
|
||||
const setActiveSession = useSessionStore((s) => s.setActiveSession)
|
||||
const setActiveView = useUIStore((s) => s.setActiveView)
|
||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
|
||||
const closeModal = useUIStore((s) => s.closeModal)
|
||||
const activeModal = useUIStore((s) => s.activeModal)
|
||||
const stopGeneration = useChatStore((s) => s.stopGeneration)
|
||||
@ -34,8 +35,12 @@ export function useKeyboardShortcuts() {
|
||||
// Cmd+K — Focus search (sidebar search input)
|
||||
if (meta && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
const searchInput = document.querySelector('aside input[type="text"]') as HTMLInputElement
|
||||
searchInput?.focus()
|
||||
setSidebarOpen(true)
|
||||
requestAnimationFrame(() => {
|
||||
const searchInput = document.querySelector('#sidebar-search') as HTMLInputElement | null
|
||||
searchInput?.focus()
|
||||
searchInput?.select()
|
||||
})
|
||||
}
|
||||
|
||||
// Escape — Close modal or clear state
|
||||
@ -56,5 +61,5 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [closeModal, setActiveSession, setActiveView, stopGeneration])
|
||||
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration])
|
||||
}
|
||||
|
||||
@ -31,6 +31,8 @@ export const en = {
|
||||
'sidebar.timeGroup.last7days': 'Last 7 days',
|
||||
'sidebar.timeGroup.last30days': 'Last 30 days',
|
||||
'sidebar.timeGroup.older': 'Older',
|
||||
'sidebar.collapse': 'Collapse sidebar',
|
||||
'sidebar.expand': 'Expand sidebar',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': 'Code',
|
||||
|
||||
@ -33,6 +33,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.last7days': '最近 7 天',
|
||||
'sidebar.timeGroup.last30days': '最近 30 天',
|
||||
'sidebar.timeGroup.older': '更早',
|
||||
'sidebar.collapse': '折叠侧边栏',
|
||||
'sidebar.expand': '展开侧边栏',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': '代码',
|
||||
|
||||
@ -146,8 +146,11 @@
|
||||
|
||||
/* Layout dimensions */
|
||||
--sidebar-width: 280px;
|
||||
--sidebar-rail-width: 72px;
|
||||
--titlebar-height: 40px;
|
||||
--statusbar-height: 36px;
|
||||
--motion-sidebar-duration: 280ms;
|
||||
--motion-sidebar-easing: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
/* Surface aliases */
|
||||
--color-surface-sidebar: var(--color-surface-container-low);
|
||||
@ -424,6 +427,151 @@ button, input, textarea, select, a, [role="button"] {
|
||||
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
|
||||
}
|
||||
|
||||
.sidebar-shell {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: width var(--motion-sidebar-duration) var(--motion-sidebar-easing);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.sidebar-shell[data-state="closed"] {
|
||||
width: var(--sidebar-rail-width);
|
||||
}
|
||||
|
||||
.sidebar-panel {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
transition:
|
||||
background-color var(--motion-sidebar-duration) var(--motion-sidebar-easing),
|
||||
border-color var(--motion-sidebar-duration) var(--motion-sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-panel[data-state="closed"] {
|
||||
width: var(--sidebar-rail-width);
|
||||
min-width: var(--sidebar-rail-width);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button {
|
||||
color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
|
||||
background: transparent;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:hover {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--color-surface-hover) 34%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:active {
|
||||
transform: translateY(0.5px);
|
||||
background: color-mix(in srgb, var(--color-surface-hover) 54%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--open {
|
||||
color: color-mix(in srgb, var(--color-text-secondary) 68%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--open:hover {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 82%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 78%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed:active {
|
||||
}
|
||||
|
||||
.sidebar-toggle-icon {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-toggle-chevron {
|
||||
stroke: currentColor;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.sidebar-toggle-chevron {
|
||||
opacity: 0.95;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.85;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.sidebar-toggle-icon--open .sidebar-toggle-chevron {
|
||||
transform: translateX(-0.2px);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed .sidebar-toggle-chevron {
|
||||
stroke-width: 2;
|
||||
transform: translateX(0.4px);
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
max-width var(--motion-sidebar-duration) var(--motion-sidebar-easing),
|
||||
opacity calc(var(--motion-sidebar-duration) - 40ms) ease,
|
||||
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-copy--visible {
|
||||
max-width: 240px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-copy--hidden {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height var(--motion-sidebar-duration) var(--motion-sidebar-easing),
|
||||
opacity calc(var(--motion-sidebar-duration) - 40ms) ease,
|
||||
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-section--visible {
|
||||
max-height: 1200px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-section--hidden {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-shell,
|
||||
.sidebar-panel,
|
||||
.sidebar-copy,
|
||||
.sidebar-section {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-prose .md-table-wrap table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user