mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': 'それ以前',
|
||||
'sidebar.collapse': 'サイドバーを折りたたむ',
|
||||
'sidebar.expand': 'サイドバーを展開',
|
||||
'sidebar.resize': 'サイドバーの幅を調整',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': 'コード',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '그 이전',
|
||||
'sidebar.collapse': '사이드바 접기',
|
||||
'sidebar.expand': '사이드바 펼치기',
|
||||
'sidebar.resize': '사이드바 너비 조절',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': '코드',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '更早',
|
||||
'sidebar.collapse': '摺疊側邊欄',
|
||||
'sidebar.expand': '展開側邊欄',
|
||||
'sidebar.resize': '拖曳調整側邊欄寬度',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': '程式碼',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '更早',
|
||||
'sidebar.collapse': '折叠侧边栏',
|
||||
'sidebar.expand': '展开侧边栏',
|
||||
'sidebar.resize': '拖拽调整侧边栏宽度',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
'titlebar.code': '代码',
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user