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:
小橙子 2026-06-11 20:22:36 +08:00 committed by GitHub
parent e1fb2b362c
commit f4940472bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2445 additions and 1988 deletions

View File

@ -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"
/>

View File

@ -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

View File

@ -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

View File

@ -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 && (

View File

@ -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

View File

@ -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>
)}

View 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>
)
}

View 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'
}

View 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')
})
})

View 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} />
}

View 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')
})
}
})

View 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>
)
}

View File

@ -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}',

View File

@ -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} に必要な前提条件が不足しています',

View File

@ -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} 사전 요구사항이 누락되었습니다',

View File

@ -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} 缺少前置相依套件',

View File

@ -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} 缺少前置依赖',

View File

@ -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>
))}