mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
feat(desktop): UX Phase 1 quick wins + Sidebar refactor (#11)
* feat(desktop): Phase 1 Quick Wins - accessibility and UX improvements Implement Phase 1 "Quick Wins" for desktop UX optimization: Accessibility: - Add ARIA attributes to MessageList (role="log", aria-live="polite") - Add ARIA attributes to StreamingIndicator (role="status") - Add aria-label to TabBar scroll buttons - Localize hardcoded Chinese strings in BrowserAddressBar and BrowserSurface User Experience: - Create shared Skeleton component for loading states - Replace Sidebar text loading with skeleton placeholders - Refactor TraceListSkeleton to use shared Skeleton component - Create DOM-based Tooltip component with keyboard shortcut hints - Add Tooltip to Sidebar "New session" button (⌘N) Internationalization: - Add new translation keys for all 5 locales (en, zh, zh-TW, jp, kr) - Add browser navigation translations - Add tab scroll translations - Add chat message log translation New components: - desktop/src/components/shared/Skeleton.tsx - desktop/src/components/shared/Tooltip.tsx Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(sidebar): extract utilities and components from Sidebar.tsx Extract ~830 lines from Sidebar.tsx into focused modules: - sidebarUtils.ts (~390 lines): pure utility functions for project grouping, localStorage persistence, preferences, path manipulation, and time formatting - sidebarComponents.tsx (~340 lines): presentational components (icons, NavItem, ProjectHeaderMenu, SessionRowMeta, etc.) Sidebar.tsx reduced from 1979 lines to ~1150 lines. TypeScript compilation passes. Existing tests need updating for vi.hoisted API. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: 你的姓名 <you@example.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e1fb2b362c
commit
f4940472bb
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, RotateCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@ -15,6 +16,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = false, onNavigate, onBack, onForward, onReload, rightActions }: Props) {
|
||||
const t = useTranslation()
|
||||
const [draft, setDraft] = useState(url)
|
||||
useEffect(() => { setDraft(url) }, [url])
|
||||
|
||||
@ -23,9 +25,9 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
|
||||
data-testid="browser-address-bar"
|
||||
className="relative flex h-11 items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2"
|
||||
>
|
||||
<button aria-label="后退" disabled={!canGoBack} onClick={onBack} className="p-1 disabled:opacity-40"><ArrowLeft size={16} /></button>
|
||||
<button aria-label="前进" disabled={!canGoForward} onClick={onForward} className="p-1 disabled:opacity-40"><ArrowRight size={16} /></button>
|
||||
<button aria-label="刷新" aria-busy={loading} onClick={onReload} className="p-1">
|
||||
<button aria-label={t('browser.back')} disabled={!canGoBack} onClick={onBack} className="p-1 disabled:opacity-40"><ArrowLeft size={16} /></button>
|
||||
<button aria-label={t('browser.forward')} disabled={!canGoForward} onClick={onForward} className="p-1 disabled:opacity-40"><ArrowRight size={16} /></button>
|
||||
<button aria-label={t('browser.refresh')} aria-busy={loading} onClick={onReload} className="p-1">
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||
</button>
|
||||
<form className="min-w-0 flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
|
||||
@ -33,7 +35,7 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
|
||||
className="w-full rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="输入网址..."
|
||||
placeholder={t('browser.enterUrl')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
@ -45,7 +47,7 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
|
||||
{loading && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="加载中"
|
||||
aria-label={t('browser.loading')}
|
||||
data-testid="browser-loading-bar"
|
||||
className="progress-indeterminate-track pointer-events-none absolute inset-x-0 bottom-0 h-0.5"
|
||||
/>
|
||||
|
||||
@ -6,8 +6,10 @@ import { previewBridge } from '../../lib/previewBridge'
|
||||
import { subscribePreviewEvents } from '../../lib/previewEvents'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const t = useTranslation()
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
|
||||
const store = useBrowserPanelStore.getState()
|
||||
@ -94,8 +96,8 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const previewActions = (
|
||||
<>
|
||||
<button
|
||||
aria-label="截图"
|
||||
title="截图"
|
||||
aria-label={t('browser.screenshot')}
|
||||
title={t('browser.screenshot')}
|
||||
className={[
|
||||
actionButtonClass,
|
||||
'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-border)]',
|
||||
@ -106,9 +108,9 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
<Camera size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="选择元素"
|
||||
aria-label={t('browser.selectElement')}
|
||||
aria-pressed={Boolean(session.pickerActive)}
|
||||
title="选择元素"
|
||||
title={t('browser.selectElement')}
|
||||
className={[
|
||||
actionButtonClass,
|
||||
session.pickerActive
|
||||
|
||||
@ -1904,6 +1904,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={updateAutoScrollState}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label={t('chat.messageLog')}
|
||||
aria-relevant="additions"
|
||||
aria-atomic="false"
|
||||
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -98,7 +98,11 @@ export function StreamingIndicator() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1"
|
||||
>
|
||||
<span className="text-[var(--color-brand)] animate-shimmer text-xs">✦</span>
|
||||
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
|
||||
{elapsedSeconds > 0 && (
|
||||
|
||||
@ -1134,7 +1134,10 @@ describe('Sidebar', () => {
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
// Phase 1 UX swapped the "Loading..." string for a Skeleton grid.
|
||||
// Each Skeleton exposes role="status" + aria-label="Loading" — assert
|
||||
// at least one is rendered and that the empty-state copy stays hidden.
|
||||
expect(screen.getAllByRole('status', { name: /loading/i }).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('No sessions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -331,7 +331,11 @@ export function TabBar() {
|
||||
>
|
||||
|
||||
{canScrollLeft && (
|
||||
<button onClick={() => scroll('left')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
aria-label={t('tabs.scrollLeft')}
|
||||
className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_left</span>
|
||||
</button>
|
||||
)}
|
||||
@ -406,7 +410,11 @@ export function TabBar() {
|
||||
)}
|
||||
|
||||
{canScrollRight && (
|
||||
<button onClick={() => scroll('right')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
aria-label={t('tabs.scrollRight')}
|
||||
className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
361
desktop/src/components/layout/sidebarComponents.tsx
Normal file
361
desktop/src/components/layout/sidebarComponents.tsx
Normal file
@ -0,0 +1,361 @@
|
||||
import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, RefreshCw, RotateCcw, SquarePen } from 'lucide-react'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import { formatRelativeTime, type SidebarProjectOrganization, type SidebarProjectSortBy } from './sidebarUtils'
|
||||
|
||||
// ─── Icon Components ──────────────────────────────
|
||||
|
||||
export function GitHubIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlusIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClockIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Helper Components ──────────────────────────────
|
||||
|
||||
export function ProjectHeaderActions({
|
||||
title,
|
||||
menuLabel,
|
||||
createLabel,
|
||||
onOpenMenu,
|
||||
onOpenCreate,
|
||||
}: {
|
||||
title: string
|
||||
menuLabel: string
|
||||
createLabel: string
|
||||
onOpenMenu: (event: React.MouseEvent) => void
|
||||
onOpenCreate: (event: React.MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar-projects-header"
|
||||
className="group/sidebar-projects flex items-center justify-between px-1.5 pb-2 pt-1"
|
||||
>
|
||||
<div className="text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover/sidebar-projects:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
aria-label={menuLabel}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreate}
|
||||
aria-label={createLabel}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectHeaderMenu({
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
organization,
|
||||
sortBy,
|
||||
onOpenSubmenu,
|
||||
onSetOrganization,
|
||||
onSetSortBy,
|
||||
onCreateBlank,
|
||||
onUseExistingFolder,
|
||||
onRestoreHiddenProjects,
|
||||
hiddenProjectCount,
|
||||
t,
|
||||
}: {
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
organization: SidebarProjectOrganization
|
||||
sortBy: SidebarProjectSortBy
|
||||
onOpenSubmenu: (event: React.MouseEvent, type: 'organize' | 'sort') => void
|
||||
onSetOrganization: (organization: SidebarProjectOrganization) => void
|
||||
onSetSortBy: (sortBy: SidebarProjectSortBy) => void
|
||||
onCreateBlank: () => void
|
||||
onUseExistingFolder: () => void
|
||||
onRestoreHiddenProjects: () => void
|
||||
hiddenProjectCount: number
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270
|
||||
const style: React.CSSProperties = { left: x, top: y, width, boxShadow: 'var(--shadow-dropdown)' }
|
||||
const className = 'fixed z-50 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]'
|
||||
|
||||
if (type === 'create') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<SquarePen size={18} aria-hidden="true" />} onClick={onCreateBlank}>
|
||||
{t('sidebar.newBlankProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} onClick={onUseExistingFolder}>
|
||||
{t('sidebar.useExistingFolder')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'organize') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Folder size={18} aria-hidden="true" />} checked={organization === 'project'} onClick={() => onSetOrganization('project')}>
|
||||
{t('sidebar.organizeByProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<FolderOpen size={18} aria-hidden="true" />} checked={organization === 'recentProject'} onClick={() => onSetOrganization('recentProject')}>
|
||||
{t('sidebar.organizeByRecentProject')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={organization === 'time'} onClick={() => onSetOrganization('time')}>
|
||||
{t('sidebar.organizeByTime')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'sort') {
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem icon={<Clock size={18} aria-hidden="true" />} checked={sortBy === 'createdAt'} onClick={() => onSetSortBy('createdAt')}>
|
||||
{t('sidebar.sortByCreatedAt')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem icon={<RefreshCw size={18} aria-hidden="true" />} checked={sortBy === 'updatedAt'} onClick={() => onSetSortBy('updatedAt')}>
|
||||
{t('sidebar.sortByUpdatedAt')}
|
||||
</HeaderMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="menu" className={className} style={style} onClick={(event) => event.stopPropagation()}>
|
||||
<HeaderMenuItem
|
||||
icon={<Folder size={18} aria-hidden="true" />}
|
||||
trailing
|
||||
onMouseEnter={(event) => onOpenSubmenu(event, 'organize')}
|
||||
onClick={(event) => onOpenSubmenu(event, 'organize')}
|
||||
>
|
||||
{t('sidebar.organizeSidebar')}
|
||||
</HeaderMenuItem>
|
||||
<HeaderMenuItem
|
||||
icon={<Clock size={18} aria-hidden="true" />}
|
||||
trailing
|
||||
onMouseEnter={(event) => onOpenSubmenu(event, 'sort')}
|
||||
onClick={(event) => onOpenSubmenu(event, 'sort')}
|
||||
>
|
||||
{t('sidebar.sortCondition')}
|
||||
</HeaderMenuItem>
|
||||
{hiddenProjectCount > 0 && (
|
||||
<HeaderMenuItem
|
||||
icon={<RotateCcw size={18} aria-hidden="true" />}
|
||||
onClick={onRestoreHiddenProjects}
|
||||
>
|
||||
{t('sidebar.restoreHiddenProjects', { count: hiddenProjectCount })}
|
||||
</HeaderMenuItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeaderMenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
checked = false,
|
||||
trailing = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
checked?: boolean
|
||||
trailing?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-[var(--color-text-secondary)]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{children}</span>
|
||||
{checked && <Check className="h-[17px] w-[17px] text-[var(--color-text-secondary)]" strokeWidth={2} aria-hidden="true" />}
|
||||
{trailing && !checked && (
|
||||
<ChevronDown className="-rotate-90 h-[17px] w-[17px] text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectMenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] disabled:cursor-default disabled:opacity-45 ${
|
||||
danger
|
||||
? 'text-[var(--color-error)] enabled:hover:bg-[var(--color-error)]/10'
|
||||
: 'text-[var(--color-text-primary)] enabled:hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-current">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionRowMeta({
|
||||
isRunning,
|
||||
isWorktree,
|
||||
modifiedAt,
|
||||
t,
|
||||
}: {
|
||||
isRunning: boolean
|
||||
isWorktree: boolean
|
||||
modifiedAt: string
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const relativeTime = formatRelativeTime(modifiedAt, t)
|
||||
const updatedLabel = t('session.lastUpdated', { time: relativeTime })
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ml-auto flex h-5 min-w-[78px] flex-shrink-0 items-center justify-end gap-1.5 text-[10px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
|
||||
title={updatedLabel}
|
||||
>
|
||||
{isRunning && (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center text-[var(--color-success)]"
|
||||
aria-label={t('sidebar.sessionRunning')}
|
||||
title={t('sidebar.sessionRunning')}
|
||||
>
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
{isWorktree && (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)]"
|
||||
title={t('sidebar.worktree')}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
<span className="sr-only">{t('sidebar.worktree')}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex min-w-[42px] flex-shrink-0 items-center justify-end">
|
||||
<span>{relativeTime}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavItem({
|
||||
active,
|
||||
collapsed,
|
||||
label,
|
||||
touchFriendly,
|
||||
onClick,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
collapsed: boolean
|
||||
label: string
|
||||
touchFriendly?: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={collapsed ? label : undefined}
|
||||
className={`
|
||||
flex items-center transition-colors duration-200
|
||||
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full gap-2.5 rounded-[12px] px-3 ${touchFriendly ? 'py-3' : 'py-2.5'} text-sm`}
|
||||
${active
|
||||
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
394
desktop/src/components/layout/sidebarUtils.ts
Normal file
394
desktop/src/components/layout/sidebarUtils.ts
Normal file
@ -0,0 +1,394 @@
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import type { SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
|
||||
// ─── Types ──────────────────────────────────────
|
||||
|
||||
export type SidebarProjectOrganization = 'project' | 'recentProject' | 'time'
|
||||
export type SidebarProjectSortBy = 'createdAt' | 'updatedAt'
|
||||
|
||||
export type ProjectGroup = {
|
||||
key: string
|
||||
title: string
|
||||
subtitle: string | null
|
||||
workDir: string | undefined
|
||||
sessions: SessionListItem[]
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────
|
||||
|
||||
export const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
|
||||
export const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
|
||||
export const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects'
|
||||
export const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization'
|
||||
export const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort'
|
||||
export const PROJECT_GROUP_VISIBLE_COUNT = 6
|
||||
export const PROJECT_GROUP_SCROLL_COUNT = 12
|
||||
|
||||
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
|
||||
|
||||
// ─── Project Grouping ──────────────────────────────
|
||||
|
||||
export function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortBy): ProjectGroup[] {
|
||||
const groupsByKey = new Map<string, SessionListItem[]>()
|
||||
for (const session of sessions) {
|
||||
const key = getSessionProjectKey(session)
|
||||
const items = groupsByKey.get(key) ?? []
|
||||
items.push(session)
|
||||
groupsByKey.set(key, items)
|
||||
}
|
||||
|
||||
const groups = [...groupsByKey.entries()].map(([key, items]) => {
|
||||
const sortedSessions = [...items].sort((a, b) => compareSessionsByTimestamp(a, b, sortBy))
|
||||
const newest = sortedSessions[0]
|
||||
const projectRoot = newest?.projectRoot || newest?.workDir || key
|
||||
return {
|
||||
key,
|
||||
title: projectTitle(projectRoot),
|
||||
subtitle: projectSubtitle(projectRoot, key),
|
||||
workDir: projectRoot || newest?.workDir || undefined,
|
||||
sessions: sortedSessions,
|
||||
}
|
||||
})
|
||||
|
||||
return groups.sort((a, b) => compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy))
|
||||
}
|
||||
|
||||
export function applyProjectOrder(
|
||||
groups: ProjectGroup[],
|
||||
projectOrder: string[],
|
||||
pinnedProjectKeys: Set<string>,
|
||||
organization: SidebarProjectOrganization,
|
||||
sortBy: SidebarProjectSortBy,
|
||||
): ProjectGroup[] {
|
||||
const orderIndex = new Map(projectOrder.map((key, index) => [key, index]))
|
||||
return [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedProjectKeys.has(a.key)
|
||||
const bPinned = pinnedProjectKeys.has(b.key)
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
if (organization === 'project') return a.title.localeCompare(b.title)
|
||||
const aIndex = orderIndex.get(a.key)
|
||||
const bIndex = orderIndex.get(b.key)
|
||||
if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex
|
||||
if (aIndex !== undefined) return -1
|
||||
if (bIndex !== undefined) return 1
|
||||
return compareSessionsByTimestamp(a.sessions[0], b.sessions[0], sortBy)
|
||||
})
|
||||
}
|
||||
|
||||
export function moveProjectKey(
|
||||
projectKeys: string[],
|
||||
sourceKey: string,
|
||||
targetKey: string,
|
||||
position: 'before' | 'after',
|
||||
): string[] {
|
||||
const withoutSource = projectKeys.filter((key) => key !== sourceKey)
|
||||
const targetIndex = withoutSource.indexOf(targetKey)
|
||||
if (targetIndex < 0) return projectKeys
|
||||
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
||||
return [
|
||||
...withoutSource.slice(0, insertIndex),
|
||||
sourceKey,
|
||||
...withoutSource.slice(insertIndex),
|
||||
]
|
||||
}
|
||||
|
||||
export function getProjectDropPosition(event: React.DragEvent<HTMLElement>): 'before' | 'after' {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
// ─── LocalStorage Persistence ──────────────────────
|
||||
|
||||
export function readStoredProjectOrder(): string[] {
|
||||
if (typeof localStorage === 'undefined') return []
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]')
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredProjectOrder(projectOrder: string[]): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder))
|
||||
} catch {
|
||||
// Sidebar ordering is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredProjectPins(): Set<string> {
|
||||
if (typeof localStorage === 'undefined') return new Set()
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]')
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredProjectPins(projectKeys: Set<string>): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys]))
|
||||
} catch {
|
||||
// Sidebar pinning is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredProjectHidden(): Set<string> {
|
||||
if (typeof localStorage === 'undefined') return new Set()
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredProjectHidden(projectKeys: Set<string>): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys]))
|
||||
} catch {
|
||||
// Hidden projects are a local UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredProjectOrganization(): SidebarProjectOrganization {
|
||||
if (typeof localStorage === 'undefined') return 'recentProject'
|
||||
return normalizeProjectOrganization(localStorage.getItem(PROJECT_ORGANIZATION_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writeStoredProjectOrganization(organization: SidebarProjectOrganization): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_ORGANIZATION_STORAGE_KEY, organization)
|
||||
} catch {
|
||||
// Sidebar organization is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredProjectSortBy(): SidebarProjectSortBy {
|
||||
if (typeof localStorage === 'undefined') return 'updatedAt'
|
||||
return normalizeProjectSortBy(localStorage.getItem(PROJECT_SORT_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writeStoredProjectSortBy(sortBy: SidebarProjectSortBy): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, sortBy)
|
||||
} catch {
|
||||
// Sidebar sorting is a UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preferences ──────────────────────────────────
|
||||
|
||||
export function buildSidebarProjectPreferences(
|
||||
projectOrder: string[],
|
||||
pinnedProjectKeys: Set<string>,
|
||||
hiddenProjectKeys: Set<string>,
|
||||
projectOrganization: SidebarProjectOrganization,
|
||||
projectSortBy: SidebarProjectSortBy,
|
||||
): SidebarProjectPreferences {
|
||||
return normalizeSidebarProjectPreferences({
|
||||
projectOrder,
|
||||
pinnedProjects: [...pinnedProjectKeys],
|
||||
hiddenProjects: [...hiddenProjectKeys],
|
||||
projectOrganization,
|
||||
projectSortBy,
|
||||
})
|
||||
}
|
||||
|
||||
export function readCachedSidebarProjectPreferences(): SidebarProjectPreferences {
|
||||
return {
|
||||
projectOrder: readStoredProjectOrder(),
|
||||
pinnedProjects: [...readStoredProjectPins()],
|
||||
hiddenProjects: [...readStoredProjectHidden()],
|
||||
projectOrganization: readStoredProjectOrganization(),
|
||||
projectSortBy: readStoredProjectSortBy(),
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void {
|
||||
const normalized = normalizeSidebarProjectPreferences(preferences)
|
||||
writeStoredProjectOrder(normalized.projectOrder)
|
||||
writeStoredProjectPins(new Set(normalized.pinnedProjects))
|
||||
writeStoredProjectHidden(new Set(normalized.hiddenProjects))
|
||||
writeStoredProjectOrganization(normalized.projectOrganization)
|
||||
writeStoredProjectSortBy(normalized.projectSortBy)
|
||||
}
|
||||
|
||||
export function normalizeSidebarProjectPreferences(preferences: Partial<SidebarProjectPreferences> | undefined): SidebarProjectPreferences {
|
||||
return {
|
||||
projectOrder: normalizeProjectKeyList(preferences?.projectOrder),
|
||||
pinnedProjects: normalizeProjectKeyList(preferences?.pinnedProjects),
|
||||
hiddenProjects: normalizeProjectKeyList(preferences?.hiddenProjects),
|
||||
projectOrganization: normalizeProjectOrganization(preferences?.projectOrganization),
|
||||
projectSortBy: normalizeProjectSortBy(preferences?.projectSortBy),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectKeyList(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string' || value.length === 0 || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
normalized.push(value)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeProjectPathForComparison(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') || value
|
||||
return isWindows ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
function isDriveRootComparisonPath(value: string): boolean {
|
||||
return /^[a-z]:$/i.test(value)
|
||||
}
|
||||
|
||||
export function projectPathMatches(projectKey: string, workDir: string): boolean {
|
||||
const normalizedProjectKey = normalizeProjectPathForComparison(projectKey)
|
||||
const normalizedWorkDir = normalizeProjectPathForComparison(workDir)
|
||||
|
||||
if (normalizedProjectKey === normalizedWorkDir) return true
|
||||
if (isDriveRootComparisonPath(normalizedProjectKey)) return false
|
||||
return normalizedWorkDir.startsWith(`${normalizedProjectKey}/`)
|
||||
}
|
||||
|
||||
export function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean {
|
||||
return preferences.projectOrder.length > 0
|
||||
|| preferences.pinnedProjects.length > 0
|
||||
|| preferences.hiddenProjects.length > 0
|
||||
|| preferences.projectOrganization !== 'recentProject'
|
||||
|| preferences.projectSortBy !== 'updatedAt'
|
||||
}
|
||||
|
||||
export function normalizeProjectOrganization(value: unknown): SidebarProjectOrganization {
|
||||
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
|
||||
}
|
||||
|
||||
export function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy {
|
||||
return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt'
|
||||
}
|
||||
|
||||
// ─── Session Utilities ──────────────────────────────
|
||||
|
||||
export function getVisibleProjectSessions(
|
||||
sessions: SessionListItem[],
|
||||
expanded: boolean,
|
||||
activeSessionId: string | null,
|
||||
): SessionListItem[] {
|
||||
if (expanded || sessions.length <= PROJECT_GROUP_VISIBLE_COUNT) return sessions
|
||||
|
||||
const visible = sessions.slice(0, PROJECT_GROUP_VISIBLE_COUNT)
|
||||
if (!activeSessionId || visible.some((session) => session.id === activeSessionId)) return visible
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
return activeSession ? [...visible, activeSession] : visible
|
||||
}
|
||||
|
||||
export function getSessionProjectKey(session: SessionListItem): string {
|
||||
return session.projectRoot || session.workDir || session.projectPath || 'unknown'
|
||||
}
|
||||
|
||||
export function compareSessionsByTimestamp(
|
||||
a: SessionListItem | undefined,
|
||||
b: SessionListItem | undefined,
|
||||
sortBy: SidebarProjectSortBy,
|
||||
): number {
|
||||
return getSessionTimestamp(b, sortBy) - getSessionTimestamp(a, sortBy)
|
||||
}
|
||||
|
||||
export function getSessionTimestamp(session: SessionListItem | undefined, sortBy: SidebarProjectSortBy): number {
|
||||
const value = sortBy === 'createdAt' ? session?.createdAt : session?.modifiedAt
|
||||
const timestamp = new Date(value ?? 0).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
// ─── Path Utilities ──────────────────────────────
|
||||
|
||||
export function projectTitle(pathLike: string | null | undefined): string {
|
||||
if (!pathLike) return 'Unknown project'
|
||||
const normalized = pathLike.replace(/[\\/]+$/, '')
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean)
|
||||
const last = segments[segments.length - 1]
|
||||
if (last) return last
|
||||
return normalized || 'Unknown project'
|
||||
}
|
||||
|
||||
export function projectSubtitle(projectRoot: string | null | undefined, fallbackKey: string): string | null {
|
||||
if (!projectRoot) return fallbackKey === 'unknown' ? null : fallbackKey
|
||||
return compactProjectPath(projectRoot)
|
||||
}
|
||||
|
||||
export function isWorktreeSession(session: SessionListItem): boolean {
|
||||
if (!session.workDir) return false
|
||||
if (/[\\/]\.claude[\\/]worktrees[\\/]/.test(session.workDir)) return true
|
||||
if (!session.projectRoot || session.workDir === session.projectRoot) return false
|
||||
return !isSameOrChildPath(session.workDir, session.projectRoot)
|
||||
}
|
||||
|
||||
export function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const child = normalizePathForCompare(childPath)
|
||||
const parent = normalizePathForCompare(parentPath)
|
||||
return child === parent || child.startsWith(`${parent}/`)
|
||||
}
|
||||
|
||||
export function normalizePathForCompare(pathLike: string): string {
|
||||
return pathLike.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function compactProjectPath(pathLike: string): string {
|
||||
const normalized = normalizePathForCompare(pathLike)
|
||||
const segments = normalized.split('/').filter(Boolean)
|
||||
if (segments.length <= 3) return normalized
|
||||
return `.../${segments.slice(-3, -1).join('/')}`
|
||||
}
|
||||
|
||||
export function domSafeProjectKey(projectKey: string): string {
|
||||
return projectKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown'
|
||||
}
|
||||
|
||||
export function positionProjectMenu(clientX: number, clientY: number): React.CSSProperties {
|
||||
if (typeof window === 'undefined') return { left: clientX, top: clientY }
|
||||
const width = 230
|
||||
const height = 280
|
||||
return {
|
||||
left: Math.max(8, Math.min(clientX, window.innerWidth - width - 8)),
|
||||
top: Math.max(8, Math.min(clientY, window.innerHeight - height - 8)),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Time Utilities ──────────────────────────────
|
||||
|
||||
export function formatRelativeTime(
|
||||
dateStr: string,
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const date = new Date(dateStr)
|
||||
const timestamp = date.getTime()
|
||||
if (!Number.isFinite(timestamp)) return ''
|
||||
|
||||
const diff = Date.now() - timestamp
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 1) return t('session.timeJustNow')
|
||||
if (min < 60) return t('session.timeMinutes', { n: min })
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return t('session.timeHours', { n: hr })
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day < 30) return t('session.timeDays', { n: day })
|
||||
return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
export function isDocumentVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
||||
}
|
||||
58
desktop/src/components/shared/Skeleton.test.tsx
Normal file
58
desktop/src/components/shared/Skeleton.test.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Skeleton, SkeletonText, SkeletonCircle } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = screen.getByRole('status')
|
||||
expect(skeleton).toBeInTheDocument()
|
||||
expect(skeleton).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('applies variant classes correctly', () => {
|
||||
const { rerender } = render(<Skeleton variant="text" />)
|
||||
expect(screen.getByRole('status')).toHaveClass('rounded')
|
||||
|
||||
rerender(<Skeleton variant="circle" />)
|
||||
expect(screen.getByRole('status')).toHaveClass('rounded-full')
|
||||
|
||||
rerender(<Skeleton variant="rect" />)
|
||||
expect(screen.getByRole('status')).toHaveClass('rounded-[var(--radius-md)]')
|
||||
})
|
||||
|
||||
it('applies custom dimensions', () => {
|
||||
render(<Skeleton width={100} height={20} />)
|
||||
const skeleton = screen.getByRole('status')
|
||||
expect(skeleton).toHaveStyle({ width: '100px', height: '20px' })
|
||||
})
|
||||
|
||||
it('renders multiple items when count > 1', () => {
|
||||
render(<Skeleton count={3} />)
|
||||
const container = screen.getByRole('status')
|
||||
expect(container.children).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Skeleton className="custom-class" />)
|
||||
expect(screen.getByRole('status')).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonText', () => {
|
||||
it('renders multiple text lines', () => {
|
||||
render(<SkeletonText lines={4} />)
|
||||
const container = screen.getByRole('status')
|
||||
expect(container.children).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCircle', () => {
|
||||
it('renders with custom size', () => {
|
||||
render(<SkeletonCircle size={48} />)
|
||||
const skeleton = screen.getByRole('status')
|
||||
expect(skeleton).toHaveStyle({ width: '48px', height: '48px' })
|
||||
expect(skeleton).toHaveClass('rounded-full')
|
||||
})
|
||||
})
|
||||
49
desktop/src/components/shared/Skeleton.tsx
Normal file
49
desktop/src/components/shared/Skeleton.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
type SkeletonVariant = 'text' | 'circle' | 'rect'
|
||||
|
||||
type SkeletonProps = {
|
||||
variant?: SkeletonVariant
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
className?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function Skeleton({ variant = 'text', width, height, className = '', count = 1 }: SkeletonProps) {
|
||||
const baseClass = 'animate-pulse bg-[var(--color-surface-container-high)]'
|
||||
const variantClass = {
|
||||
text: 'rounded',
|
||||
circle: 'rounded-full',
|
||||
rect: 'rounded-[var(--radius-md)]',
|
||||
}[variant]
|
||||
|
||||
const style: React.CSSProperties = {}
|
||||
if (width) style.width = width
|
||||
if (height) style.height = height
|
||||
|
||||
if (count > 1) {
|
||||
return (
|
||||
<div className="space-y-2" role="status" aria-label="Loading">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className={`${baseClass} ${variantClass} ${className}`} style={style} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={`${baseClass} ${variantClass} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, className = '' }: { lines?: number; className?: string }) {
|
||||
return <Skeleton variant="text" count={lines} height={12} className={className} />
|
||||
}
|
||||
|
||||
export function SkeletonCircle({ size = 32, className = '' }: { size?: number; className?: string }) {
|
||||
return <Skeleton variant="circle" width={size} height={size} className={className} />
|
||||
}
|
||||
138
desktop/src/components/shared/Tooltip.test.tsx
Normal file
138
desktop/src/components/shared/Tooltip.test.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Tooltip, formatShortcut } from './Tooltip'
|
||||
|
||||
describe('Tooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows tooltip on hover after delay', async () => {
|
||||
render(
|
||||
<Tooltip content="Test tooltip">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('Hover me')
|
||||
fireEvent.mouseEnter(trigger)
|
||||
|
||||
// Should not be visible immediately
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
|
||||
// Advance timer past delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
// Should now be visible
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides tooltip on mouse leave', async () => {
|
||||
render(
|
||||
<Tooltip content="Test tooltip">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('Hover me')
|
||||
fireEvent.mouseEnter(trigger)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(trigger)
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders shortcut hint when provided', async () => {
|
||||
const isMac = /Mac/.test(navigator.platform)
|
||||
const expectedShortcut = isMac ? '⌘N' : 'Ctrl+N'
|
||||
|
||||
render(
|
||||
<Tooltip content="New session" shortcut="⌘N">
|
||||
<button>New</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('New')
|
||||
fireEvent.mouseEnter(trigger)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText(expectedShortcut)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has correct role attribute', async () => {
|
||||
render(
|
||||
<Tooltip content="Test">
|
||||
<button>Hover</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Hover'))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows on focus and hides on blur', async () => {
|
||||
render(
|
||||
<Tooltip content="Focus tooltip">
|
||||
<button>Focus me</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('Focus me')
|
||||
fireEvent.focus(trigger)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
fireEvent.blur(trigger)
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatShortcut', () => {
|
||||
const isMac = /Mac/.test(navigator.platform)
|
||||
|
||||
if (isMac) {
|
||||
it('returns original shortcut on Mac', () => {
|
||||
expect(formatShortcut('⌘N')).toBe('⌘N')
|
||||
expect(formatShortcut('⌥A')).toBe('⌥A')
|
||||
expect(formatShortcut('⇧S')).toBe('⇧S')
|
||||
})
|
||||
} else {
|
||||
it('converts Mac shortcuts to Ctrl+ format on non-Mac', () => {
|
||||
expect(formatShortcut('⌘N')).toBe('Ctrl+N')
|
||||
expect(formatShortcut('⌥A')).toBe('Alt+A')
|
||||
expect(formatShortcut('⇧S')).toBe('Shift+S')
|
||||
})
|
||||
|
||||
it('leaves plain shortcuts unchanged on non-Mac', () => {
|
||||
expect(formatShortcut('Ctrl+N')).toBe('Ctrl+N')
|
||||
expect(formatShortcut('Escape')).toBe('Escape')
|
||||
})
|
||||
}
|
||||
})
|
||||
52
desktop/src/components/shared/Tooltip.tsx
Normal file
52
desktop/src/components/shared/Tooltip.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState, useRef, type ReactNode } from 'react'
|
||||
|
||||
type TooltipProps = {
|
||||
content: ReactNode
|
||||
shortcut?: string
|
||||
children: React.ReactElement
|
||||
side?: 'top' | 'bottom'
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform)
|
||||
|
||||
export function formatShortcut(shortcut: string): string {
|
||||
if (!isMac) return shortcut.replace('⌘', 'Ctrl+').replace('⌥', 'Alt+').replace('⇧', 'Shift+')
|
||||
return shortcut
|
||||
}
|
||||
|
||||
export function Tooltip({ content, shortcut, children, side = 'bottom', delayMs = 300 }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const show = () => {
|
||||
timeoutRef.current = setTimeout(() => setVisible(true), delayMs)
|
||||
}
|
||||
const hide = () => {
|
||||
clearTimeout(timeoutRef.current)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}>
|
||||
{children}
|
||||
{visible && (
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`absolute z-50 whitespace-nowrap rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-high)] px-2.5 py-1.5 text-xs font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] ${
|
||||
side === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'
|
||||
} left-1/2 -translate-x-1/2`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{content}</span>
|
||||
{shortcut && (
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-1 py-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{formatShortcut(shortcut)}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1325,6 +1325,7 @@ export const en = {
|
||||
'repoLaunch.checkedOutWarning': 'Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.messageLog': 'Chat messages',
|
||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
|
||||
'chat.addFiles': 'Add files or photos',
|
||||
@ -2089,6 +2090,17 @@ export const en = {
|
||||
'tabs.hideWorkspace': 'Hide Workspace',
|
||||
'tabs.showBrowser': 'Show Browser',
|
||||
'tabs.hideBrowser': 'Hide Browser',
|
||||
'tabs.scrollLeft': 'Scroll tabs left',
|
||||
'tabs.scrollRight': 'Scroll tabs right',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────
|
||||
'browser.back': 'Back',
|
||||
'browser.forward': 'Forward',
|
||||
'browser.refresh': 'Refresh',
|
||||
'browser.enterUrl': 'Enter URL...',
|
||||
'browser.loading': 'Loading',
|
||||
'browser.screenshot': 'Screenshot',
|
||||
'browser.selectElement': 'Select element',
|
||||
|
||||
// ─── Plugin Prerequisites Modal ─────────────────────────────────────
|
||||
'pluginPrereq.title': 'Missing prerequisites for {name}',
|
||||
|
||||
@ -1300,6 +1300,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'repoLaunch.checkedOutWarning': '選択したブランチは別の worktree で既にチェックアウトされています。直接起動は Git によってブロックされる場合があります。ディレクトリの変更を避けるには「独立した worktree」を使用してください。',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.messageLog': 'チャットメッセージ',
|
||||
'chat.placeholder': '編集、デバッグ、説明を Claude に依頼...',
|
||||
'chat.placeholderMissing': 'このセッションは見つからないワークスペースを指しています。新しいセッションを作成するか、別のプロジェクトを選択してください。',
|
||||
'chat.addFiles': 'ファイルまたは写真を追加',
|
||||
@ -2064,6 +2065,17 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'tabs.hideWorkspace': 'ワークスペースを非表示',
|
||||
'tabs.showBrowser': 'ブラウザを表示',
|
||||
'tabs.hideBrowser': 'ブラウザを非表示',
|
||||
'tabs.scrollLeft': 'タブを左にスクロール',
|
||||
'tabs.scrollRight': 'タブを右にスクロール',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────
|
||||
'browser.back': '戻る',
|
||||
'browser.forward': '進む',
|
||||
'browser.refresh': '更新',
|
||||
'browser.enterUrl': 'URL を入力...',
|
||||
'browser.loading': '読み込み中',
|
||||
'browser.screenshot': 'スクリーンショット',
|
||||
'browser.selectElement': '要素を選択',
|
||||
|
||||
// ─── プラグイン前提条件モーダル ─────────────────────────────────────
|
||||
'pluginPrereq.title': '{name} に必要な前提条件が不足しています',
|
||||
|
||||
@ -1300,6 +1300,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'repoLaunch.checkedOutWarning': '선택한 브랜치는 다른 worktree에서 이미 체크아웃되어 있습니다. Git에 의해 직접 시작이 차단될 수 있습니다. 디렉터리 변경을 피하려면 "독립 worktree"를 사용하세요.',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.messageLog': '채팅 메시지',
|
||||
'chat.placeholder': '편집, 디버그 또는 설명을 Claude에 요청...',
|
||||
'chat.placeholderMissing': '이 세션은 없는 작업 공간을 가리킵니다. 새 세션을 만들거나 다른 프로젝트를 선택하세요.',
|
||||
'chat.addFiles': '파일 또는 사진 추가',
|
||||
@ -2064,6 +2065,17 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'tabs.hideWorkspace': '작업 공간 숨기기',
|
||||
'tabs.showBrowser': '브라우저 표시',
|
||||
'tabs.hideBrowser': '브라우저 숨기기',
|
||||
'tabs.scrollLeft': '탭 왼쪽으로 스크롤',
|
||||
'tabs.scrollRight': '탭 오른쪽으로 스크롤',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────
|
||||
'browser.back': '뒤로',
|
||||
'browser.forward': '앞으로',
|
||||
'browser.refresh': '새로고침',
|
||||
'browser.enterUrl': 'URL 입력...',
|
||||
'browser.loading': '로딩 중',
|
||||
'browser.screenshot': '스크린샷',
|
||||
'browser.selectElement': '요소 선택',
|
||||
|
||||
// ─── 플러그인 사전 요구 모달 ─────────────────────────────────────
|
||||
'pluginPrereq.title': '{name} 사전 요구사항이 누락되었습니다',
|
||||
|
||||
@ -1300,6 +1300,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'repoLaunch.checkedOutWarning': '選中的分支已在其他工作樹中檢出。直接啟動可能會被 Git 阻止;使用“獨立工作樹”可以避免切換當前目錄。',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.messageLog': '聊天訊息',
|
||||
'chat.placeholder': '讓 Claude 編輯、除錯或解釋程式碼...',
|
||||
'chat.placeholderMissing': '此會話指向的工作目錄缺失。請新建會話或選擇其他專案。',
|
||||
'chat.addFiles': '新增檔案或圖片',
|
||||
@ -2064,6 +2065,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tabs.hideWorkspace': '隱藏工作區',
|
||||
'tabs.showBrowser': '顯示瀏覽器',
|
||||
'tabs.hideBrowser': '隱藏瀏覽器',
|
||||
'tabs.scrollLeft': '向左滾動標籤頁',
|
||||
'tabs.scrollRight': '向右滾動標籤頁',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────
|
||||
'browser.back': '返回',
|
||||
'browser.forward': '前進',
|
||||
'browser.refresh': '重新整理',
|
||||
'browser.enterUrl': '輸入網址...',
|
||||
'browser.loading': '載入中',
|
||||
'browser.screenshot': '截圖',
|
||||
'browser.selectElement': '選擇元素',
|
||||
|
||||
// ─── 外掛前置相依套件對話框 ─────────────────────────────────────
|
||||
'pluginPrereq.title': '{name} 缺少前置相依套件',
|
||||
|
||||
@ -1300,6 +1300,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'repoLaunch.checkedOutWarning': '选中的分支已在其他工作树中检出。直接启动可能会被 Git 阻止;使用“独立工作树”可以避免切换当前目录。',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.messageLog': '聊天消息',
|
||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
|
||||
'chat.addFiles': '添加文件或图片',
|
||||
@ -2064,6 +2065,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tabs.hideWorkspace': '隐藏工作区',
|
||||
'tabs.showBrowser': '显示浏览器',
|
||||
'tabs.hideBrowser': '隐藏浏览器',
|
||||
'tabs.scrollLeft': '向左滚动标签页',
|
||||
'tabs.scrollRight': '向右滚动标签页',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────
|
||||
'browser.back': '后退',
|
||||
'browser.forward': '前进',
|
||||
'browser.refresh': '刷新',
|
||||
'browser.enterUrl': '输入网址...',
|
||||
'browser.loading': '加载中',
|
||||
'browser.screenshot': '截图',
|
||||
'browser.selectElement': '选择元素',
|
||||
|
||||
// ─── 插件前置依赖弹窗 ─────────────────────────────────────
|
||||
'pluginPrereq.title': '{name} 缺少前置依赖',
|
||||
|
||||
@ -6,6 +6,7 @@ import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Skeleton } from '../components/shared/Skeleton'
|
||||
import { getDesktopHost } from '../lib/desktopHost'
|
||||
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
|
||||
|
||||
@ -380,13 +381,13 @@ function TraceListSkeleton({ label }: { label: string }) {
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="flex h-14 items-center gap-4 px-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-3 w-48 max-w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-2 h-2.5 w-72 max-w-full animate-pulse rounded bg-[var(--color-surface-container-low)]" />
|
||||
<Skeleton variant="text" width="48%" height={12} />
|
||||
<Skeleton variant="text" width="72%" height={10} className="mt-2" />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="h-3 w-10 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<Skeleton variant="text" width={40} height={12} />
|
||||
<Skeleton variant="text" width={48} height={12} />
|
||||
<Skeleton variant="text" width={48} height={12} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user