feat(desktop): put the sidebar width under the user's control and trim the nav chrome

Three things about the left column, all reported from the same screenshot.

The brand mark beside the wordmark was clutter. Expanded, the sidebar already
says "Claude Code Haha" in the headline face, and the 32px seal next to it
repeats what the words carry. Removing it outright emptied the header on the
72px rail, though — the copy there is width-clamped to zero by
`.sidebar-copy--hidden`, so nothing was left to identify the app. The mark now
renders only when collapsed, at `sm`: two C's and the seal bar, without the
cursor arrow that reads as a stray orange wedge at that size. BrandSeal already
sheds parts as it shrinks, so this is the size ladder doing its job rather than
a new variant.

The settings rail was 260px for a column whose longest label is two words. It
is now 220px. 200px was the first choice and it was wrong: measured in a real
browser against the live stylesheet, the Japanese "コンピューター操作" needs
122px of text box and 200px leaves 115px, so it truncated. The threshold sits
at 210px; 220px keeps ten pixels of headroom over the worst locale.

The sidebar itself is now resizable. An 8px handle on its right edge drives the
width between 240 and 480px, persisted to localStorage, with a double click
back to 300 and arrow-key steps for the keyboard. Dragging left past 240 pins
there until the pointer crosses 180, which collapses to the rail; coming back
out past 200 re-opens it. The 20px gap between those two thresholds is
hysteresis — with one boundary the sidebar flickers open and closed on any
tremor of the hand. A drag that ends in a collapse deliberately does not commit
its width, so re-opening from the toggle restores the size the user chose
rather than whatever value the pointer happened to sweep through.

The live width travels as a CSS variable written imperatively onto the shell,
never through React state. Sidebar re-renders during a streaming turn would
otherwise land between drag frames and fight the pointer, which is the failure
that made the pet animation stutter. The store is written once, when the drag
settles.

That variable exposed a bug the unit tests could not see. `#sidebar-shell` sits
behind AppShell's startup gate, so it mounts a render later than the hook. With
a plain object ref, `shellRef.current` is still null on the single pass the
width effect ever runs, and because neither dependency changes afterwards the
effect never fires again — the remembered width never reaches the DOM and every
launch silently falls back to the stylesheet's 300px. Only visible by loading
the real app: the harness in a test mounts the shell and the hook together.
Fixed with a callback ref, and `useSidebarResize.test.tsx` now models the gate;
that case was confirmed to fail against the object-ref version.

Verified in a browser against the dev server: drag 300→420 persists as "420",
crossing 150 settles the shell at the 72px rail while localStorage still holds
the chosen width, and the truncation thresholds above were measured by
substituting each locale's longest label into a live tab.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-27 05:05:04 +08:00
parent 49f1974007
commit 8eba29bdd4
14 changed files with 505 additions and 3 deletions
@@ -431,7 +431,7 @@ describe('Settings > General tab', () => {
expect(activeItem).toHaveAttribute('aria-current', 'page')
const rail = activeItem.parentElement?.parentElement
expect(rail?.className).toContain('w-[260px]')
expect(rail?.className).toContain('w-[220px]')
})
it('marks the pure white appearance theme as selected', () => {
@@ -9,6 +9,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { useElectronWindowDragRegions } from '../../hooks/useElectronWindowDragRegions'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import {
H5ConnectionRequiredError,
initializeDesktopServerUrl,
@@ -57,7 +58,9 @@ export function AppShell() {
? sessions.find((session) => session.id === activeTabId) ?? null
: null
const wasMobileShellRef = useRef(false)
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
const sidebarResize = useSidebarResize(!isMobileShell)
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
const isActiveChatTab = isChatTab(activeTab)
const mobileSessionTitle = activeSession?.title || activeTab?.title || t('session.untitled')
@@ -292,6 +295,7 @@ export function AppShell() {
) : null}
<div
id="sidebar-shell"
ref={sidebarResize.shellRef}
data-testid="sidebar-shell"
data-state={effectiveSidebarOpen ? 'open' : 'closed'}
data-mobile={isMobileShell ? 'true' : 'false'}
@@ -301,6 +305,18 @@ export function AppShell() {
{!isMobileShell || effectiveSidebarOpen ? (
<Sidebar isMobile={isMobileShell} onRequestClose={() => setEffectiveSidebarOpen(false)} />
) : null}
{!isMobileShell ? (
<div
data-testid="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resize')}
aria-valuenow={effectiveSidebarOpen ? sidebarWidth : 0}
tabIndex={0}
className="sidebar-resize-handle"
{...sidebarResize.handleProps}
/>
) : null}
</div>
<main
id="content-area"
@@ -1193,6 +1193,25 @@ describe('Sidebar', () => {
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open')
})
it('shows the brand mark only on the rail, where the wordmark is clamped away', async () => {
render(<Sidebar />)
// Scope to the wordmark's own row — the GitHub link in the same header is
// also an svg and would answer a looser query.
const brandRow = () => screen.getByText('Haha').closest('div')
// Expanded, the name carries the brand and the mark beside it is clutter.
expect(brandRow()?.querySelector('svg')).toBeNull()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
})
// Collapsed, the copy is width-clamped to zero, so the mark is the only
// thing left to identify the app.
expect(brandRow()?.querySelector('svg')).not.toBeNull()
})
it('renders search controls without the removed embedded project filter', () => {
render(<Sidebar />)
+6 -1
View File
@@ -680,8 +680,13 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
className={`px-3 pb-2 ${isDesktopRuntime && !isWindows ? 'pt-[44px]' : 'pt-3'}`}
>
<div className={`flex ${expanded ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
{/* The mark only stands in for the wordmark on the rail. Expanded,
the name says it better and the icon beside it is just clutter;
collapsed, the copy is width-clamped to zero and the header would
otherwise be empty. `sm` is the cleanest cut of the mark — two C's
and the seal bar, no cursor or sparkles to turn to mush at 24px. */}
<div className={`flex min-w-0 items-center ${expanded ? 'gap-2.5' : 'justify-center'}`}>
<BrandSeal size={expanded ? 'md' : 'lg'} />
{!expanded ? <BrandSeal size="sm" /> : null}
<span
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-base font-bold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
+229
View File
@@ -0,0 +1,229 @@
import { useState } from 'react'
import { act, createEvent, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { useSidebarResize } from './useSidebarResize'
import {
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
useUIStore,
} from '../stores/uiStore'
/**
* `mounted` models AppShell's startup gate: the shell is absent on the first
* render and appears only once the workspace bootstrap resolves.
*/
function Harness({ enabled = true, mounted = true }: { enabled?: boolean; mounted?: boolean }) {
const { shellRef, handleProps } = useSidebarResize(enabled)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
if (!mounted) return <div>starting…</div>
return (
<div ref={shellRef} data-testid="shell" data-state={sidebarOpen ? 'open' : 'closed'}>
<div data-testid="handle" tabIndex={0} {...handleProps} />
</div>
)
}
/** Flips `mounted` from false to true on demand, like the bootstrap finishing. */
function GatedHarness() {
const [mounted, setMounted] = useState(false)
return (
<>
<button type="button" onClick={() => setMounted(true)}>boot</button>
<Harness mounted={mounted} />
</>
)
}
function shellWidth(): number {
return Number.parseInt(screen.getByTestId('shell').style.getPropertyValue('--sidebar-width'), 10)
}
/**
* jsdom has no PointerEvent, so `button` and `clientX` never survive a plain
* fireEvent.pointerDown init — they have to be pinned onto the event by hand.
*/
function pressHandle(button = 0) {
const event = createEvent.pointerDown(screen.getByTestId('handle'))
Object.defineProperty(event, 'button', { value: button })
fireEvent(screen.getByTestId('handle'), event)
}
function movePointerTo(clientX: number) {
const event = createEvent.pointerMove(window)
Object.defineProperty(event, 'clientX', { value: clientX })
fireEvent(window, event)
}
function releasePointer() {
fireEvent(window, createEvent.pointerUp(window))
}
function drag(...points: number[]) {
pressHandle()
for (const point of points) movePointerTo(point)
releasePointer()
}
describe('useSidebarResize', () => {
beforeEach(() => {
localStorage.clear()
useUIStore.setState({ sidebarOpen: true, sidebarWidth: SIDEBAR_DEFAULT_WIDTH })
})
it('publishes the stored width onto the shell', () => {
render(<Harness />)
expect(shellWidth()).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('publishes the stored width to a shell that mounts after the first render', async () => {
useUIStore.setState({ sidebarWidth: 380 })
render(<GatedHarness />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'boot' }))
})
// Regression: the shell lives behind AppShell's startup gate, so a plain
// object ref reads null on the pass that publishes the width and the
// remembered size silently falls back to the stylesheet default.
expect(shellWidth()).toBe(380)
})
it('tracks the pointer while dragging and commits the width on release', () => {
render(<Harness />)
drag(360)
expect(shellWidth()).toBe(360)
expect(useUIStore.getState().sidebarWidth).toBe(360)
expect(localStorage.getItem('cc-haha-sidebar-width')).toBe('360')
})
it('clamps the width to the safe range instead of following the pointer past it', () => {
render(<Harness />)
drag(SIDEBAR_MAX_WIDTH + 200)
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_MAX_WIDTH)
// Just inside the collapse threshold: pinned at the minimum, still open.
drag(SIDEBAR_MIN_WIDTH - 30)
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_MIN_WIDTH)
expect(useUIStore.getState().sidebarOpen).toBe(true)
})
it('collapses once the pointer crosses the threshold past the minimum', () => {
render(<Harness />)
drag(120)
expect(useUIStore.getState().sidebarOpen).toBe(false)
})
it('keeps the remembered width when a drag ends in a collapse', () => {
useUIStore.setState({ sidebarWidth: 420 })
render(<Harness />)
drag(300, 120)
expect(useUIStore.getState().sidebarOpen).toBe(false)
// Both the store and the live variable still describe the chosen width, so
// re-opening from the toggle button restores it rather than a mid-drag one.
expect(useUIStore.getState().sidebarWidth).toBe(420)
expect(shellWidth()).toBe(420)
})
it('re-expands when the pointer comes back out past the expand threshold', () => {
render(<Harness />)
pressHandle()
movePointerTo(120)
expect(useUIStore.getState().sidebarOpen).toBe(false)
// Inside the hysteresis gap the sidebar must stay collapsed.
movePointerTo(SIDEBAR_MIN_WIDTH - 50)
expect(useUIStore.getState().sidebarOpen).toBe(false)
movePointerTo(320)
releasePointer()
expect(useUIStore.getState().sidebarOpen).toBe(true)
expect(useUIStore.getState().sidebarWidth).toBe(320)
})
it('ignores pointer movement that is not part of a drag', () => {
render(<Harness />)
movePointerTo(400)
expect(shellWidth()).toBe(SIDEBAR_DEFAULT_WIDTH)
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('restores the default width on a double click', () => {
useUIStore.setState({ sidebarWidth: 440 })
render(<Harness />)
fireEvent.doubleClick(screen.getByTestId('handle'))
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH)
expect(shellWidth()).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('ignores non-primary buttons', () => {
render(<Harness />)
pressHandle(2)
movePointerTo(400)
expect(shellWidth()).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('leaves the width to the stylesheet and does not drag when disabled', () => {
render(<Harness enabled={false} />)
expect(screen.getByTestId('shell').style.getPropertyValue('--sidebar-width')).toBe('')
pressHandle()
movePointerTo(400)
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('clears the drag styling on the body when the pointer is cancelled', () => {
render(<Harness />)
pressHandle()
expect(document.body).toHaveClass('sidebar-resizing')
fireEvent(window, createEvent.pointerCancel(window))
expect(document.body).not.toHaveClass('sidebar-resizing')
})
describe('keyboard', () => {
it('steps the width with the arrow keys', () => {
render(<Harness />)
const handle = screen.getByTestId('handle')
fireEvent.keyDown(handle, { key: 'ArrowRight' })
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH + 20)
fireEvent.keyDown(handle, { key: 'ArrowLeft' })
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH)
})
it('collapses at the minimum and re-expands from the rail', () => {
useUIStore.setState({ sidebarWidth: SIDEBAR_MIN_WIDTH })
render(<Harness />)
const handle = screen.getByTestId('handle')
fireEvent.keyDown(handle, { key: 'ArrowLeft' })
expect(useUIStore.getState().sidebarOpen).toBe(false)
expect(useUIStore.getState().sidebarWidth).toBe(SIDEBAR_MIN_WIDTH)
fireEvent.keyDown(handle, { key: 'ArrowRight' })
expect(useUIStore.getState().sidebarOpen).toBe(true)
})
})
})
+146
View File
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MIN_WIDTH,
clampSidebarWidth,
useUIStore,
} from '../stores/uiStore'
// Dragging left past the minimum keeps the sidebar pinned at that minimum until
// the pointer crosses COLLAPSE_AT, which drops it to the rail; dragging back out
// past EXPAND_AT re-opens it. The gap between the two is hysteresis — with a
// single boundary the sidebar flickers open/closed on every jitter of the hand.
const COLLAPSE_AT = SIDEBAR_MIN_WIDTH - 60
const EXPAND_AT = SIDEBAR_MIN_WIDTH - 40
const RESIZE_STEP = 20
const WIDTH_VAR = '--sidebar-width'
const RESIZING_CLASS = 'sidebar-shell--resizing'
/**
* Drag-to-resize for the sidebar, with drag-past-the-minimum to collapse.
*
* The live width goes onto the shell as a CSS variable written imperatively —
* never through React state — so the re-render storm from a streaming session
* cannot fight the drag loop. The store is written only when the drag settles,
* and a drag that ends in a collapse leaves the remembered width alone so
* re-opening restores the size the user had chosen.
*/
export function useSidebarResize(enabled: boolean) {
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
const draggingRef = useRef(false)
// A callback ref, not a plain one: the shell is behind the startup gate, so
// it mounts on a later render than this hook. An object ref would still read
// null on the only pass the width effect ever runs, and the remembered width
// would never reach the DOM.
const [shell, setShell] = useState<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (!shell) return
// On a narrow viewport the shell is a fixed-position drawer sized off the
// viewport, so hand the variable back rather than pinning it to a width
// that was chosen for a desktop window.
if (!enabled) {
shell.style.removeProperty(WIDTH_VAR)
return
}
if (draggingRef.current) return
shell.style.setProperty(WIDTH_VAR, `${sidebarWidth}px`)
}, [enabled, shell, sidebarWidth])
const applyWidth = useCallback((width: number) => {
shell?.style.setProperty(WIDTH_VAR, `${clampSidebarWidth(width)}px`)
}, [shell])
const stopDrag = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
document.body.classList.remove('sidebar-resizing')
if (!shell) return
shell.classList.remove(RESIZING_CLASS)
if (!useUIStore.getState().sidebarOpen) return
const width = Number.parseInt(shell.style.getPropertyValue(WIDTH_VAR), 10)
if (Number.isFinite(width)) useUIStore.getState().setSidebarWidth(width)
}, [shell])
const trackPointer = useCallback((clientX: number) => {
if (!shell) return
// The sidebar is flush against the window's left edge, so the pointer's
// clientX is the requested width outright — no grab-offset to drift.
const { sidebarOpen, sidebarWidth: storedWidth, setSidebarOpen } = useUIStore.getState()
if (sidebarOpen && clientX < COLLAPSE_AT) {
// Restore the remembered width before collapsing: the rail ignores this
// variable, and leaving the abandoned mid-drag value behind would shrink
// the sidebar the next time it is opened from the toggle button.
shell.classList.remove(RESIZING_CLASS)
shell.style.setProperty(WIDTH_VAR, `${storedWidth}px`)
setSidebarOpen(false)
return
}
if (!sidebarOpen && clientX <= EXPAND_AT) return
shell.classList.add(RESIZING_CLASS)
applyWidth(clientX)
if (!sidebarOpen) setSidebarOpen(true)
}, [applyWidth, shell])
// Listening on the window rather than capturing on the handle keeps the drag
// alive when the pointer runs past the window edge, which is exactly where a
// drag-to-collapse gesture tends to end up.
useEffect(() => {
const handleMove = (event: PointerEvent) => {
if (!draggingRef.current) return
trackPointer(event.clientX)
}
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', stopDrag)
window.addEventListener('pointercancel', stopDrag)
return () => {
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', stopDrag)
window.removeEventListener('pointercancel', stopDrag)
stopDrag()
}
}, [stopDrag, trackPointer])
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!enabled || event.button !== 0 || !shell) return
event.preventDefault()
draggingRef.current = true
document.body.classList.add('sidebar-resizing')
if (useUIStore.getState().sidebarOpen) shell.classList.add(RESIZING_CLASS)
}, [enabled, shell])
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!enabled) return
const { sidebarOpen, sidebarWidth: current, setSidebarOpen, setSidebarWidth } = useUIStore.getState()
if (event.key === 'ArrowLeft') {
event.preventDefault()
if (!sidebarOpen) return
if (current <= SIDEBAR_MIN_WIDTH) setSidebarOpen(false)
else setSidebarWidth(current - RESIZE_STEP)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
if (sidebarOpen) setSidebarWidth(current + RESIZE_STEP)
else setSidebarOpen(true)
}
}, [enabled])
const onDoubleClick = useCallback(() => {
if (!enabled || !shell) return
useUIStore.getState().setSidebarWidth(SIDEBAR_DEFAULT_WIDTH)
// The layout effect is a no-op when the store value did not change, so
// restate the variable for the drag-then-double-click-back case.
shell.style.setProperty(WIDTH_VAR, `${SIDEBAR_DEFAULT_WIDTH}px`)
}, [enabled, shell])
return {
shellRef: setShell,
handleProps: { onPointerDown, onKeyDown, onDoubleClick },
}
}
+1
View File
@@ -123,6 +123,7 @@ export const en = {
'sidebar.timeGroup.older': 'Older',
'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand sidebar',
'sidebar.resize': 'Resize sidebar',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': 'Code',
+1
View File
@@ -125,6 +125,7 @@ export const jp: Record<TranslationKey, string> = {
'sidebar.timeGroup.older': 'それ以前',
'sidebar.collapse': 'サイドバーを折りたたむ',
'sidebar.expand': 'サイドバーを展開',
'sidebar.resize': 'サイドバーの幅を調整',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': 'コード',
+1
View File
@@ -125,6 +125,7 @@ export const kr: Record<TranslationKey, string> = {
'sidebar.timeGroup.older': '그 이전',
'sidebar.collapse': '사이드바 접기',
'sidebar.expand': '사이드바 펼치기',
'sidebar.resize': '사이드바 너비 조절',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': '코드',
+1
View File
@@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.timeGroup.older': '更早',
'sidebar.collapse': '摺疊側邊欄',
'sidebar.expand': '展開側邊欄',
'sidebar.resize': '拖曳調整側邊欄寬度',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': '程式碼',
+1
View File
@@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.timeGroup.older': '更早',
'sidebar.collapse': '折叠侧边栏',
'sidebar.expand': '展开侧边栏',
'sidebar.resize': '拖拽调整侧边栏宽度',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': '代码',
+4 -1
View File
@@ -226,7 +226,10 @@ export function Settings() {
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-[260px] flex-shrink-0 flex flex-col overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-4">
{/* Narrow enough that the rail is not a gutter of dead space, wide
enough that the longest label in any locale — the Japanese
"コンピューター操作" — still clears the truncation on TabButton. */}
<div className="w-[220px] flex-shrink-0 flex flex-col overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-4">
<div className="flex-1 flex flex-col gap-0.5">
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
+26
View File
@@ -25,6 +25,23 @@ import {
} from '../theme/systemAppearance'
const ACTIVE_SETTINGS_TAB_STORAGE_KEY = 'cc-haha-active-settings-tab'
const SIDEBAR_WIDTH_STORAGE_KEY = 'cc-haha-sidebar-width'
export const SIDEBAR_MIN_WIDTH = 240
export const SIDEBAR_MAX_WIDTH = 480
export const SIDEBAR_DEFAULT_WIDTH = 300
export function clampSidebarWidth(width: number): number {
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)))
}
function getStoredSidebarWidth(): number {
try {
const stored = Number(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY))
if (Number.isFinite(stored) && stored > 0) return clampSidebarWidth(stored)
} catch { /* localStorage unavailable */ }
return SIDEBAR_DEFAULT_WIDTH
}
const SETTINGS_TABS = [
'providers',
@@ -234,6 +251,8 @@ type UIStore = {
/** Which ink palette the dark half of "follow the system" resolves to. */
darkTheme: DarkThemeMode
sidebarOpen: boolean
/** Expanded-state sidebar width in px, user-resizable within the clamp range. */
sidebarWidth: number
activeView: ActiveView
activeSettingsTab: SettingsTab
pendingSettingsTab: SettingsTab | null
@@ -246,6 +265,7 @@ type UIStore = {
toggleTheme: () => void
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setSidebarWidth: (width: number) => void
setActiveView: (view: ActiveView) => void
setActiveSettingsTab: (tab: SettingsTab) => void
setPendingSettingsTab: (tab: SettingsTab | null) => void
@@ -264,6 +284,7 @@ export const useUIStore = create<UIStore>((set) => ({
lightTheme: readStoredLightTheme(),
darkTheme: readStoredDarkTheme(),
sidebarOpen: true,
sidebarWidth: getStoredSidebarWidth(),
activeView: 'code',
activeSettingsTab: getStoredSettingsTab(),
pendingSettingsTab: null,
@@ -329,6 +350,11 @@ export const useUIStore = create<UIStore>((set) => ({
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarWidth: (width) => {
const clamped = clampSidebarWidth(width)
persist(SIDEBAR_WIDTH_STORAGE_KEY, String(clamped))
set({ sidebarWidth: clamped })
},
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (tab) => {
try { localStorage.setItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY, tab) } catch { /* noop */ }
+53
View File
@@ -1436,6 +1436,7 @@ button, input, textarea, select, a, [role="button"] {
}
.sidebar-shell {
position: relative;
width: var(--sidebar-width);
flex-shrink: 0;
overflow: hidden;
@@ -1447,6 +1448,58 @@ button, input, textarea, select, a, [role="button"] {
width: var(--sidebar-rail-width);
}
/* While the pointer is driving the width, the animation must be off or every
frame chases the previous one and the edge lags behind the cursor. */
.sidebar-shell--resizing,
.sidebar-shell--resizing .sidebar-panel {
transition: none;
}
.sidebar-resize-handle {
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 8px;
z-index: 10;
cursor: col-resize;
background: transparent;
transition: background-color 140ms ease;
touch-action: none;
}
.sidebar-resize-handle::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 2px;
background: transparent;
transition: background-color 140ms ease;
}
.sidebar-resize-handle:hover::after,
.sidebar-resize-handle:focus-visible::after,
.sidebar-shell--resizing .sidebar-resize-handle::after {
background: var(--color-brand);
}
.sidebar-resize-handle:focus-visible {
outline: none;
}
/* Keep the resize cursor and kill text selection for the whole drag, no matter
what the pointer ends up over once capture takes it outside the handle. */
body.sidebar-resizing {
cursor: col-resize;
user-select: none;
}
body.sidebar-resizing * {
cursor: col-resize !important;
}
.app-shell--mobile .sidebar-shell {
position: fixed;
inset: 0 auto 0 0;