refactor(desktop): adopt the component library in layout and workspace

Native buttons across the three directories: 81 -> 49.

`Toast` and `UpdateChecker` move in from `shared/` — one caller each,
both application singletons that belong with the shell.

`Sidebar` was the hard case. Its 21 icon buttons hover to
`--color-sidebar-item-hover`, which differs from `--color-surface-hover`
in all three themes (it is a translucent white tuned for the sidebar's
gradient). Overriding via className means two arbitrary `hover:bg-[…]`
values competing, with the winner decided by stylesheet order — so
`IconButton` grew a `surface="sidebar"` instead.

Both local `ToolbarIconButton` adapters are deleted; `pressed` was the
only thing they still added, and it brings `aria-pressed` they never had.

`ProjectHeaderMenu` gains `forwardRef` so its four menus can share one
`useDismissable`. Their triggers are click toggles, so `triggerRef` is
what keeps a second click from reopening what it just closed.

Three retry buttons drop five `/N` alpha modifiers on the way.

Left native (49): menu items, tab items, tree nodes, file rows, the OS
titlebar controls, `WorkspaceDiffSurface`'s cells, and the two sidebar
collapse toggles — their `sidebar-toggle-button` class carries an active
transform and drives the chevron animation, not just size and radius.

`TitleBar.tsx` is deleted: zero importers. The audit listed its settings
button among the unnamed controls, but it never rendered.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-26 16:02:32 +08:00
parent 359b375a79
commit 3eb05bf33e
18 changed files with 404 additions and 449 deletions
@@ -119,11 +119,11 @@ vi.mock('../../pages/TraceSession', () => ({
),
}))
vi.mock('../shared/Toast', () => ({
vi.mock('@/components/layout/Toast', () => ({
ToastContainer: () => null,
}))
vi.mock('../shared/UpdateChecker', () => ({
vi.mock('@/components/layout/UpdateChecker', () => ({
UpdateChecker: () => <div>updates loaded</div>,
}))
+11 -13
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast'
import { UpdateChecker } from '../shared/UpdateChecker'
import { ToastContainer } from '@/components/layout/Toast'
import { UpdateChecker } from '@/components/layout/UpdateChecker'
import { StatusDot } from '@/components/ui/Badge'
import { IconButton } from '@/components/ui/IconButton'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
@@ -310,19 +312,15 @@ export function AppShell() {
data-testid="mobile-session-header"
className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2"
>
<button
type="button"
<IconButton
data-testid="mobile-sidebar-toggle"
icon={effectiveSidebarOpen ? 'close' : 'menu'}
label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
onClick={toggleEffectiveSidebar}
size="xl"
aria-controls="sidebar-shell"
aria-expanded={effectiveSidebarOpen}
aria-label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
onClick={toggleEffectiveSidebar}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[20px]">
{effectiveSidebarOpen ? 'close' : 'menu'}
</span>
</button>
/>
{isActiveChatTab ? (
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-bold leading-tight text-[var(--color-text-primary)]">
@@ -331,7 +329,7 @@ export function AppShell() {
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] font-medium text-[var(--color-text-tertiary)]">
{activeTab?.status === 'running' ? (
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-secondary)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
<StatusDot tone="success" pulse />
{t('session.active')}
</span>
) : null}
@@ -1,8 +1,8 @@
import type { FormEvent } from 'react'
import { useState } from 'react'
import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { Button } from '../shared/Button'
import { Input } from '../shared/Input'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
type H5ConnectionViewProps = {
initialServerUrl?: string | null
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { TargetIcon } from '../common/TargetIcon'
import { useDismissable } from '@/hooks/useDismissable'
import { TargetIcon } from '@/components/composite/TargetIcon'
type Props = {
path: string | null | undefined
@@ -27,26 +28,14 @@ export function OpenProjectMenu({ path }: Props) {
void ensureTargets()
}, [ensureTargets, path])
useEffect(() => {
if (!open) return
const handleDismiss = useCallback(() => setOpen(false), [])
const handleDocumentMouseDown = (event: MouseEvent) => {
const target = event.target as Node
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleDocumentMouseDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleDocumentMouseDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
useDismissable({
open,
refs: [menuRef],
triggerRef: buttonRef,
onDismiss: handleDismiss,
})
const primaryTarget = useMemo(
() => targets.find((target) => target.id === primaryTargetId) ?? targets[0] ?? null,
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { Copy, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
import { Button } from '@/components/ui/Button'
import { DoctorPanel } from '../doctor/DoctorPanel'
import { copyTextToClipboard } from '../chat/clipboard'
import { copyTextToClipboard } from '@/lib/clipboard'
const LOG_MARKER = '\n\nRecent server logs:\n'
@@ -99,6 +99,10 @@ vi.mock('../../i18n', () => ({
'tabs.hideWorkspace': 'Hide Workspace',
'tabs.showBrowser': 'Show Browser',
'tabs.hideBrowser': 'Hide Browser',
'tabs.scrollLeft': 'Scroll tabs left',
'tabs.scrollRight': 'Scroll tabs right',
'tabs.closeTab': 'Close {title}',
'tabs.untitled': 'Untitled',
'settings.title': 'Localized Settings',
'openProject.openProject': 'Open project',
'openProject.openIn': 'Open in {target}',
File diff suppressed because it is too large Load Diff
@@ -1,96 +0,0 @@
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
export function TitleBar() {
const { activeView, setActiveView } = useUIStore()
const t = useTranslation()
return (
<div
className="h-[var(--titlebar-height)] flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] select-none"
data-desktop-drag-region
>
{/* macOS traffic light spacer */}
<div className="w-[78px] flex-shrink-0" data-desktop-drag-region />
{/* Logo */}
<div className="flex items-center gap-2 mr-4" data-desktop-drag-region>
<span className="text-xs font-bold tracking-wider text-[var(--color-brand)] uppercase">Claude Code Companion</span>
</div>
{/* Navigation arrows */}
<div className="flex items-center gap-1 mr-4">
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
{/* Center tabs */}
<div className="flex-1 flex items-center justify-center gap-1" data-desktop-drag-region>
<TabButton
active={activeView === 'code'}
onClick={() => setActiveView('code')}
icon="code"
>
{t('titlebar.code')}
</TabButton>
<TabButton
active={activeView === 'terminal'}
onClick={() => setActiveView('terminal')}
icon="terminal"
>
{t('titlebar.terminal')}
</TabButton>
<TabButton
active={activeView === 'history'}
onClick={() => setActiveView('history')}
icon="history"
>
{t('titlebar.history')}
</TabButton>
</div>
{/* Right: Settings */}
<div className="flex items-center gap-2 mr-4">
<button className="p-1.5 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<span className="material-symbols-outlined text-[18px]">settings</span>
</button>
</div>
</div>
)
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean
onClick: () => void
icon: string
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-[var(--radius-md)] transition-colors duration-200
${active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}
`}
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
{children}
</button>
)
}
@@ -1,3 +1,4 @@
import { IconButton } from '@/components/ui/IconButton'
import { useUIStore, type Toast as ToastType } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
@@ -22,18 +23,18 @@ function ToastItem({ toast }: { toast: ToastType }) {
bg-[var(--color-surface)] rounded-[var(--radius-md)] shadow-[var(--shadow-dropdown)]
px-4 py-3 text-sm text-[var(--color-text-primary)]
${typeStyles[toast.type]}
animate-in slide-in-from-right fade-in duration-200
animate-overlay-in-right
`}
>
<div className="flex items-center justify-between gap-2">
<span>{toast.message}</span>
<button
<IconButton
icon="close"
label={t('common.dismissNotification')}
onClick={() => removeToast(toast.id)}
aria-label={t('common.dismissNotification')}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] text-lg leading-none"
>
×
</button>
size="xs"
tone="muted"
/>
</div>
</div>
)
@@ -45,7 +46,7 @@ export function ToastContainer() {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<div className="fixed bottom-4 right-4 z-[var(--z-toast)] flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { Button } from '@/components/ui/Button'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
@@ -28,7 +29,7 @@ export function UpdateChecker() {
const statusText = t('update.readyBody', { version: availableVersion })
return (
<div className="fixed bottom-4 left-1/2 z-[120] w-[min(360px,calc(100vw-2rem))] -translate-x-1/2">
<div className="fixed bottom-4 left-1/2 z-[var(--z-toast)] w-[min(360px,calc(100vw-2rem))] -translate-x-1/2">
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-3">
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('update.readyTitle')}
@@ -54,18 +55,12 @@ export function UpdateChecker() {
{status === 'downloaded' && (
<div className="mt-3 flex gap-2">
<button
onClick={() => void installUpdate()}
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
>
<Button variant="primary" size="base" onClick={() => void installUpdate()}>
{t('update.installAndRestart')}
</button>
<button
onClick={dismissPrompt}
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
</Button>
<Button variant="ghost" size="base" onClick={dismissPrompt}>
{t('update.later')}
</button>
</Button>
</div>
)}
</div>
@@ -40,6 +40,11 @@ describe('WindowControls', () => {
isMaximized.mockClear()
onResized.mockClear()
// The control labels are translated; assertions below spell the English strings.
// Seeded through localStorage rather than `setState` because `vi.resetModules()`
// below makes the test import a fresh settings store that re-reads storage.
window.localStorage.setItem('cc-haha-locale', 'en')
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
window.desktopHost = {
kind: 'electron',
@@ -75,6 +80,7 @@ describe('WindowControls', () => {
})
afterEach(() => {
window.localStorage.removeItem('cc-haha-locale')
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, 'desktopHost')
Object.defineProperty(navigator, 'platform', {
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { getDesktopHost } from '../../lib/desktopHost'
import type { DesktopHost } from '../../lib/desktopHost'
import { useTranslation } from '../../i18n'
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
@@ -8,6 +9,7 @@ const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platf
export const showWindowControls = isWindows && getDesktopHost().capabilities.windowControls
export function WindowControls() {
const t = useTranslation()
const [maximized, setMaximized] = useState(false)
const [win, setWin] = useState<DesktopHost['window'] | null>(null)
@@ -52,7 +54,7 @@ export function WindowControls() {
{/* Minimize */}
<button
onClick={() => runWindowAction(() => win.minimize())}
aria-label="Minimize window"
aria-label={t('windowControls.minimize')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
<svg width="10" height="1" viewBox="0 0 10 1">
@@ -63,7 +65,7 @@ export function WindowControls() {
{/* Maximize / Restore */}
<button
onClick={() => runWindowAction(() => win.toggleMaximize())}
aria-label={maximized ? 'Restore window' : 'Maximize window'}
aria-label={t(maximized ? 'windowControls.restore' : 'windowControls.maximize')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{maximized ? (
@@ -81,7 +83,7 @@ export function WindowControls() {
{/* Close */}
<button
onClick={() => runWindowAction(() => win.close())}
aria-label="Close window"
aria-label={t('windowControls.close')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-window-close-hover)] hover:text-white transition-colors"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
@@ -1,4 +1,6 @@
import { ArrowLeft, FolderOpen, Globe, Maximize2, X } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../../i18n'
import {
useWorkspacePanelStore,
@@ -78,14 +80,15 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
className="flex h-12 shrink-0 items-center gap-2.5 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-4"
>
{isTabVariant && (
<button
type="button"
<Button
variant="ghost"
size="base"
onClick={handleReturn}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[12px] font-medium text-[var(--color-text-secondary)] transition-[color,background-color,transform] duration-200 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30"
icon={<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />}
className="shrink-0"
>
<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
<span>{t('workbench.backToConversation')}</span>
</button>
{t('workbench.backToConversation')}
</Button>
)}
<div
role="tablist"
@@ -116,24 +119,22 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
<div className="ml-auto flex shrink-0 items-center gap-1">
{!isTabVariant && (
<button
type="button"
aria-label={t('workbench.expand')}
title={t('workbench.expand')}
<IconButton
icon={<Maximize2 size={15} strokeWidth={2} aria-hidden="true" />}
label={t('workbench.expand')}
onClick={handleExpand}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30"
>
<Maximize2 size={15} strokeWidth={2} aria-hidden="true" />
</button>
size="sm"
tone="muted"
/>
)}
<button
type="button"
aria-label={t('workbench.close')}
<IconButton
icon={<X size={16} strokeWidth={2} aria-hidden="true" />}
label={t('workbench.close')}
onClick={handleClose}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
size="sm"
tone="muted"
showTooltip={false}
/>
</div>
</header>
@@ -8,7 +8,7 @@ import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { TargetIcon } from '../common/TargetIcon'
import { TargetIcon } from '@/components/composite/TargetIcon'
function openExternal(path: string) {
void getDesktopHost().shell.openPath(path).catch(() => {})
File diff suppressed because it is too large Load Diff