feat: add action tooltips to note controls

This commit is contained in:
lucaronin
2026-04-17 02:34:41 +02:00
parent ce26c57e99
commit 22cefe4912
11 changed files with 503 additions and 462 deletions

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import type { VaultEntry } from '../types'
@@ -49,6 +49,19 @@ const defaultProps = {
onToggleDiff: vi.fn(),
}
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
act(() => {
fireEvent.focus(trigger)
})
const tooltip = await screen.findByRole('tooltip')
for (const part of parts) {
expect(tooltip).toHaveTextContent(part)
}
act(() => {
fireEvent.blur(trigger)
})
}
describe('BreadcrumbBar — drag region', () => {
it('has data-tauri-drag-region on the container', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
@@ -67,13 +80,13 @@ describe('BreadcrumbBar — drag region', () => {
describe('BreadcrumbBar — delete', () => {
it('shows delete button', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
expect(screen.getByTitle('Delete (Cmd+Delete)')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
})
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
fireEvent.click(screen.getByTitle('Delete (Cmd+Delete)'))
fireEvent.click(screen.getByRole('button', { name: 'Delete this note' }))
expect(onDelete).toHaveBeenCalledOnce()
})
})
@@ -81,40 +94,40 @@ describe('BreadcrumbBar — delete', () => {
describe('BreadcrumbBar — archive/unarchive', () => {
it('shows archive button for non-archived note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Archive')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Archive this note' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Restore this archived note' })).not.toBeInTheDocument()
})
it('shows unarchive button for archived note', () => {
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Restore this archived note' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Archive this note' })).not.toBeInTheDocument()
})
it('calls onArchive when archive button is clicked', () => {
const onArchive = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
fireEvent.click(screen.getByTitle('Archive'))
fireEvent.click(screen.getByRole('button', { name: 'Archive this note' }))
expect(onArchive).toHaveBeenCalledOnce()
})
it('calls onUnarchive when unarchive button is clicked', () => {
const onUnarchive = vi.fn()
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTitle('Unarchive'))
fireEvent.click(screen.getByRole('button', { name: 'Restore this archived note' }))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', () => {
it('shows Cmd+E on the organized toggle tooltip', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'Set note as organized' }), 'Set note as organized', '⌘E')
})
it('hides the organized toggle when the workflow is disabled', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.queryByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Set note as organized' })).not.toBeInTheDocument()
})
})
@@ -238,32 +251,32 @@ describe('BreadcrumbBar — raw editor toggle', () => {
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
})
it('shows "Back to editor" tooltip when rawMode is on', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Return to the editor' })).toBeInTheDocument()
})
it('calls onToggleRaw when raw button is clicked', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
fireEvent.click(screen.getByTitle('Raw editor'))
fireEvent.click(screen.getByRole('button', { name: 'Open the raw editor' }))
expect(onToggleRaw).toHaveBeenCalledOnce()
})
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open the raw editor' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Return to the editor' })).not.toBeInTheDocument()
})
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
})
})

View File

@@ -3,7 +3,8 @@ import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
MagnifyingGlass,
GitBranch,
@@ -96,46 +97,44 @@ function handleFilenameInputKeyDown(
}
function IconActionButton({
title,
copy,
onClick,
className,
style,
disabled,
tabIndex,
children,
testId,
}: {
title: string
copy: ActionTooltipCopy
onClick?: () => void
className?: string
style?: CSSProperties
disabled?: boolean
tabIndex?: number
children: ReactNode
testId?: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
style={style}
onClick={onClick}
disabled={disabled}
tabIndex={tabIndex}
title={title}
data-testid={testId}
>
{children}
</Button>
<ActionTooltip copy={copy} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
style={style}
onClick={onClick}
aria-label={copy.label}
aria-disabled={onClick ? undefined : true}
data-testid={testId}
>
{children}
</Button>
</ActionTooltip>
)
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
const copy: ActionTooltipCopy = { label: rawMode ? 'Return to the editor' : 'Open the raw editor' }
return (
<IconActionButton
title={rawMode ? 'Back to editor' : 'Raw editor'}
copy={copy}
onClick={onToggleRaw}
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
>
@@ -145,9 +144,13 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
const copy: ActionTooltipCopy = {
label: favorite ? 'Remove from favorites' : 'Add to favorites',
shortcut: '⌘D',
}
return (
<IconActionButton
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
copy={copy}
onClick={onToggleFavorite}
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
>
@@ -164,9 +167,13 @@ function OrganizedAction({
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
const copy: ActionTooltipCopy = {
label: organized ? 'Set note as not organized' : 'Set note as organized',
shortcut: '⌘E',
}
return (
<IconActionButton
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
copy={copy}
onClick={onToggleOrganized}
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
>
@@ -177,7 +184,7 @@ function OrganizedAction({
function SearchAction() {
return (
<IconActionButton title="Search in file" className="hover:text-foreground">
<IconActionButton copy={{ label: 'Search within this note' }} className="hover:text-foreground">
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
@@ -191,17 +198,19 @@ function DiffAction({
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
const copy: ActionTooltipCopy = diffLoading
? { label: 'Loading the diff' }
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
return (
<IconActionButton
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
copy={copy}
onClick={onToggleDiff}
disabled={diffLoading}
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
@@ -209,18 +218,22 @@ function DiffAction({
)
}
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
function PlaceholderAction({ copy, children }: { copy: ActionTooltipCopy; children: ReactNode }) {
return (
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
<IconActionButton copy={copy} style={DISABLED_ICON_STYLE}>
{children}
</IconActionButton>
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
const copy: ActionTooltipCopy = {
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
shortcut: '⇧⌘L',
}
return (
<IconActionButton
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
copy={copy}
onClick={onToggleAIChat}
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
>
@@ -236,14 +249,14 @@ function ArchiveAction({
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
return (
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
@@ -251,7 +264,7 @@ function ArchiveAction({
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
return (
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
@@ -263,7 +276,7 @@ function InspectorAction({
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
<IconActionButton copy={{ label: 'Open the properties panel', shortcut: '⌘⇧I' }} onClick={onToggleInspector} className="hover:text-foreground">
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
@@ -351,26 +364,19 @@ function SyncFilenameButton({
}) {
if (!syncStem || !onRenameFilename) return null
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename file to match title"
>
<ArrowsClockwise size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>
Rename file to match title
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename the file to match the title"
>
<ArrowsClockwise size={14} />
</Button>
</ActionTooltip>
)
}
@@ -480,14 +486,14 @@ function BreadcrumbActions({
onToggleDiff={onToggleDiff}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<PlaceholderAction title="Coming soon">
<PlaceholderAction copy={{ label: 'Backlinks are coming soon' }}>
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
<PlaceholderAction title="Coming soon">
<PlaceholderAction copy={{ label: 'More note actions are coming soon' }}>
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
</PlaceholderAction>
</div>
@@ -517,27 +523,29 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
...actionProps
}: BreadcrumbBarProps) {
return (
<div
ref={barRef}
data-tauri-drag-region
data-title-hidden=""
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
</div>
<TooltipProvider>
<div
aria-hidden="true"
ref={barRef}
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
data-title-hidden=""
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
</TooltipProvider>
)
})

View File

@@ -208,7 +208,7 @@ describe('Editor', () => {
activeTabPath: mockEntry.path,
})
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Search within this note' })).toBeInTheDocument()
})
it('hides the legacy title field for untitled draft notes', () => {
@@ -248,7 +248,7 @@ describe('Editor', () => {
onLoadDiff={async () => '+ added line'}
/>
)
const diffBtn = screen.getByTitle('Show diff')
const diffBtn = screen.getByRole('button', { name: 'Show the current diff' })
expect(diffBtn).toBeInTheDocument()
})

View File

@@ -19,6 +19,19 @@ const installedAiAgentsStatus = {
codex: { status: 'installed' as const, version: '0.37.0' },
}
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
act(() => {
fireEvent.focus(trigger)
})
const tooltip = await screen.findByRole('tooltip')
for (const part of parts) {
expect(tooltip).toHaveTextContent(part)
}
act(() => {
fireEvent.blur(trigger)
})
}
describe('StatusBar', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -46,9 +59,9 @@ describe('StatusBar', () => {
expect(onCheckForUpdates).toHaveBeenCalledOnce()
})
it('build number has "Check for updates" title', () => {
it('build number shows the update tooltip on focus', async () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={vi.fn()} />)
expect(screen.getByTitle('Check for updates')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'Check for updates' }), 'Check for updates')
})
it('does not display branch name', () => {
@@ -83,7 +96,7 @@ describe('StatusBar', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
// Click the vault button to open menu
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Work Vault')).toBeInTheDocument()
})
@@ -92,7 +105,7 @@ describe('StatusBar', () => {
const onSwitchVault = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={onSwitchVault} />)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
// Click "Work Vault"
fireEvent.click(screen.getByText('Work Vault'))
@@ -102,7 +115,7 @@ describe('StatusBar', () => {
it('closes vault menu when clicking outside', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Work Vault')).toBeInTheDocument()
// Click outside the menu
@@ -114,7 +127,7 @@ describe('StatusBar', () => {
it('toggles vault menu open and closed', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
const vaultButton = screen.getByTitle('Switch vault')
const vaultButton = screen.getByRole('button', { name: 'Switch vault' })
fireEvent.click(vaultButton)
expect(screen.getByText('Work Vault')).toBeInTheDocument()
@@ -127,7 +140,7 @@ describe('StatusBar', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Open local folder')).toBeInTheDocument()
})
@@ -136,7 +149,7 @@ describe('StatusBar', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={onOpenLocalFolder} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Open local folder'))
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
@@ -152,7 +165,7 @@ describe('StatusBar', () => {
onCloneVault={vi.fn()}
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
})
@@ -168,7 +181,7 @@ describe('StatusBar', () => {
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Clone Getting Started Vault')).toBeInTheDocument()
})
@@ -184,7 +197,7 @@ describe('StatusBar', () => {
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Clone Getting Started Vault'))
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
})
@@ -210,7 +223,7 @@ describe('StatusBar', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Open local folder'))
// Menu should close after clicking an action
expect(screen.queryByText('Open local folder')).not.toBeInTheDocument()
@@ -225,27 +238,27 @@ describe('StatusBar', () => {
expect(onClickPending).toHaveBeenCalledOnce()
})
it('pending count has title for accessibility', () => {
it('pending changes tooltip is available on keyboard focus', async () => {
render(
<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onClickPending={vi.fn()} />
)
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'View pending changes' }), 'View pending changes')
})
it('shows MCP warning badge when status is not_installed', () => {
it('shows MCP warning badge when status is not_installed', async () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('MCP server not installed — click to install')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'MCP server not installed — click to install' }), 'MCP server not installed — click to install')
})
it('shows MCP warning badge when status is no_claude_cli', () => {
it('shows MCP warning badge when status is no_claude_cli', async () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('Claude CLI not found — install it first')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'Claude CLI not found — install it first' }), 'Claude CLI not found — install it first')
})
it('hides MCP badge when status is installed', () => {
@@ -378,7 +391,7 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
it('uses a local-only tooltip for the commit button when no remote is configured', async () => {
render(
<StatusBar
noteCount={100}
@@ -390,7 +403,7 @@ describe('StatusBar', () => {
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
await expectTooltip(screen.getByRole('button', { name: 'Commit changes locally' }), 'Commit changes locally')
})
it('shows Commit button even when no modified files', () => {
@@ -403,20 +416,20 @@ describe('StatusBar', () => {
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})
it('shows Claude Code badge when installed', () => {
it('shows Claude Code badge when installed', async () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="installed" claudeCodeVersion="1.0.20" />)
const badge = screen.getByTestId('status-claude-code')
expect(badge).toBeInTheDocument()
expect(screen.getByText('Claude Code')).toBeInTheDocument()
expect(badge.getAttribute('title')).toBe('Claude Code 1.0.20')
await expectTooltip(screen.getByRole('button', { name: 'Claude Code 1.0.20' }), 'Claude Code 1.0.20')
})
it('shows Claude Code missing badge with warning when missing', () => {
it('shows Claude Code missing badge with warning when missing', async () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
const badge = screen.getByTestId('status-claude-code')
expect(badge).toBeInTheDocument()
expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
expect(badge.getAttribute('title')).toBe('Claude Code not found — click to install')
await expectTooltip(screen.getByRole('button', { name: 'Claude Code not found — click to install' }), 'Claude Code not found — click to install')
})
it('opens install URL when clicking missing Claude Code badge', () => {

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus'
import type { GitRemoteStatus, SyncStatus } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
StatusBarPrimarySection,
StatusBarSecondarySection,
@@ -97,62 +98,64 @@ export function StatusBar({
}, [])
return (
<footer
style={{
height: 30,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
}}
>
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
/>
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
onZoomReset={onZoomReset}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>
<TooltipProvider>
<footer
style={{
height: 30,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
}}
>
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
conflictCount={conflictCount}
remoteStatus={remoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
/>
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
onZoomReset={onZoomReset}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>
</TooltipProvider>
)
}

View File

@@ -1,5 +1,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'
import type { ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
vi.mock('../../utils/url', async () => {
@@ -12,6 +14,10 @@ const installedStatuses = {
codex: { status: 'installed' as const, version: '0.37.0' },
}
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
describe('AiAgentsBadge', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -1,4 +1,5 @@
import { AlertTriangle, ChevronsUpDown, Terminal } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import {
AI_AGENT_DEFINITIONS,
@@ -199,23 +200,25 @@ export function AiAgentsBadge({
<>
<span style={SEP_STYLE}>|</span>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium"
title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
data-testid="status-ai-agents"
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
<Terminal size={13} />
{triggerLabel(defaultAgent, selectedAgentReady)}
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
</span>
</Button>
</DropdownMenuTrigger>
<ActionTooltip copy={{ label: badgeTooltip(statuses, defaultAgent, guidanceStatus) }} side="top">
<DropdownMenuTrigger asChild={true}>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium"
aria-label="Open AI agent options"
data-testid="status-ai-agents"
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
<Terminal size={13} />
{triggerLabel(defaultAgent, selectedAgentReady)}
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
</span>
</Button>
</DropdownMenuTrigger>
</ActionTooltip>
<AgentMenuContent
statuses={statuses}
guidanceStatus={guidanceStatus}

View File

@@ -1,9 +1,4 @@
import { useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react'
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import {
AlertTriangle,
ArrowDown,
@@ -15,6 +10,9 @@ import {
Terminal,
} from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/react'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
@@ -48,47 +46,6 @@ const MCP_TOOLTIPS: Partial<Record<McpStatus, string>> = {
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
type HoverHandlers = {
onMouseEnter?: (event: ReactMouseEvent<HTMLSpanElement>) => void
onMouseLeave?: (event: ReactMouseEvent<HTMLSpanElement>) => void
}
interface ClaudeCodeRenderState extends HoverHandlers {
role?: 'button'
tabIndex?: number
onKeyDown?: (event: ReactKeyboardEvent<HTMLSpanElement>) => void
color: string
cursor: string
warningIcon: ReactNode
}
function createHoverHandlers(interactive: boolean): HoverHandlers {
if (!interactive) {
return {
onMouseEnter: undefined,
onMouseLeave: undefined,
}
}
return {
onMouseEnter: (event) => {
event.currentTarget.style.background = 'var(--hover)'
},
onMouseLeave: (event) => {
event.currentTarget.style.background = 'transparent'
},
}
}
function createEnterKeyHandler(onActivate?: () => void) {
return (event: ReactKeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate?.()
}
}
}
function formatElapsedSync(lastSyncTime: number | null): string {
if (!lastSyncTime) return 'Not synced'
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
@@ -103,11 +60,12 @@ function syncIconColor(status: SyncStatus): string {
return SYNC_COLORS[status] ?? 'var(--accent-green)'
}
function syncBadgeTitle(status: SyncStatus): string {
if (status === 'conflict') return 'Click to resolve conflicts'
if (status === 'syncing') return 'Syncing…'
if (status === 'pull_required') return 'Click to pull from remote and push'
return 'Click to sync now'
function syncBadgeTooltipCopy(status: SyncStatus): ActionTooltipCopy {
if (status === 'conflict') return { label: 'Resolve merge conflicts' }
if (status === 'syncing') return { label: 'Sync in progress' }
if (status === 'pull_required') return { label: 'Pull from remote and push' }
if (status === 'error') return { label: 'Retry sync' }
return { label: 'Sync now' }
}
function syncStatusText(status: SyncStatus): string {
@@ -127,10 +85,12 @@ function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): bool
return remoteStatus?.hasRemote === false
}
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
return isRemoteMissing(remoteStatus)
? 'Commit locally (no remote configured)'
: 'Commit & Push'
function commitButtonTooltipCopy(remoteStatus: GitRemoteStatus | null | undefined): ActionTooltipCopy {
return {
label: isRemoteMissing(remoteStatus)
? 'Commit changes locally'
: 'Commit and push changes',
}
}
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
@@ -154,30 +114,57 @@ function getClaudeCodeBadgeConfig(status: ClaudeCodeStatus, version?: string | n
}
}
function createClaudeCodeRenderState(
config: NonNullable<ReturnType<typeof getClaudeCodeBadgeConfig>>,
): ClaudeCodeRenderState {
if (!config.missing) {
return {
role: undefined,
tabIndex: undefined,
onKeyDown: undefined,
color: 'var(--muted-foreground)',
cursor: 'default',
warningIcon: null,
...createHoverHandlers(false),
}
}
function handleStatusBarActionKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
onClick?: () => void,
) {
if (!onClick) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onClick()
}
return {
role: 'button',
tabIndex: 0,
onKeyDown: createEnterKeyHandler(config.onActivate),
color: 'var(--accent-orange)',
cursor: 'pointer',
warningIcon: <AlertTriangle size={10} style={{ marginLeft: 2 }} />,
...createHoverHandlers(true),
}
function StatusBarAction({
copy,
children,
onClick,
testId,
ariaLabel,
className,
style,
disabled = false,
}: {
copy: ActionTooltipCopy
children: ReactNode
onClick?: () => void
testId?: string
ariaLabel?: string
className?: string
style?: CSSProperties
disabled?: boolean
}) {
return (
<ActionTooltip copy={copy} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={cn(
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
className,
)}
style={style}
onClick={disabled ? undefined : onClick}
onKeyDown={(event) => handleStatusBarActionKeyDown(event, disabled ? undefined : onClick)}
aria-label={ariaLabel ?? copy.label}
aria-disabled={disabled || undefined}
data-testid={testId}
>
{children}
</Button>
</ActionTooltip>
)
}
function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) {
@@ -405,16 +392,12 @@ export function SyncBadge({
return (
<div ref={popupRef} style={{ position: 'relative' }}>
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={syncBadgeTitle(status)}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
{formatSyncLabel(status, lastSyncTime)}
</span>
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync">
<span style={ICON_STYLE}>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
{formatSyncLabel(status, lastSyncTime)}
</span>
</StatusBarAction>
{showPopup && (
<GitStatusPopup
status={status}
@@ -433,25 +416,17 @@ export function ConflictBadge({ count, onClick }: { count: number; onClick?: ()
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
<StatusBarAction
copy={{ label: 'Resolve merge conflicts' }}
onClick={onClick}
style={{
...ICON_STYLE,
color: 'var(--destructive, #e03e3e)',
cursor: onClick ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title="Resolve merge conflicts"
onMouseEnter={onClick ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onClick ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
data-testid="status-conflict-count"
testId="status-conflict-count"
className="text-[var(--destructive,#e03e3e)]"
>
<AlertTriangle size={13} />
{count} conflict{count > 1 ? 's' : ''}
</span>
<span style={ICON_STYLE}>
<AlertTriangle size={13} />
{count} conflict{count > 1 ? 's' : ''}
</span>
</StatusBarAction>
</>
)
}
@@ -462,35 +437,29 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--accent-orange)',
color: '#fff',
borderRadius: 9,
padding: '0 5px',
fontSize: 10,
fontWeight: 600,
minWidth: 16,
lineHeight: '16px',
}}
>
{count}
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count">
<span style={ICON_STYLE}>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--accent-orange)',
color: '#fff',
borderRadius: 9,
padding: '0 5px',
fontSize: 10,
fontWeight: 600,
minWidth: 16,
lineHeight: '16px',
}}
>
{count}
</span>
Changes
</span>
Changes
</span>
</StatusBarAction>
</>
)
}
@@ -507,20 +476,12 @@ export function CommitButton({
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={createEnterKeyHandler(onClick)}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={commitButtonTitle(remoteStatus)}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"
>
<GitCommitHorizontal size={13} />
Commit
</span>
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push">
<span style={ICON_STYLE}>
<GitCommitHorizontal size={13} />
Commit
</span>
</StatusBarAction>
</>
)
}
@@ -529,25 +490,17 @@ export function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabl
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={disabled ? undefined : 'button'}
<StatusBarAction
copy={{ label: disabled ? 'History is only available for git-enabled vaults' : 'Open change history' }}
onClick={disabled ? undefined : onClick}
style={{
...ICON_STYLE,
cursor: disabled ? 'not-allowed' : 'pointer',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
opacity: disabled ? 0.4 : 1,
}}
title={disabled ? 'History is only available for git-enabled vaults' : 'View history'}
onMouseEnter={disabled ? undefined : (event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={disabled ? undefined : (event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-pulse"
testId="status-pulse"
disabled={Boolean(disabled)}
>
<Pulse size={13} />
History
</span>
<span style={ICON_STYLE}>
<Pulse size={13} />
History
</span>
</StatusBarAction>
</>
)
}
@@ -559,26 +512,18 @@ export function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?:
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={config.clickable ? 'button' : undefined}
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onClick}
style={{
...ICON_STYLE,
color: 'var(--accent-orange)',
cursor: config.clickable ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={config.tooltip}
data-testid="status-mcp"
onMouseEnter={config.clickable ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={config.clickable ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
testId="status-mcp"
className="text-[var(--accent-orange)]"
>
<Cpu size={13} />
MCP
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
<span style={ICON_STYLE}>
<Cpu size={13} />
MCP
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</StatusBarAction>
</>
)
}
@@ -587,33 +532,21 @@ export function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus;
const config = getClaudeCodeBadgeConfig(status, version)
if (!config) return null
const renderState = createClaudeCodeRenderState(config)
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={renderState.role}
tabIndex={renderState.tabIndex}
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onActivate}
onKeyDown={renderState.onKeyDown}
style={{
...ICON_STYLE,
color: renderState.color,
cursor: renderState.cursor,
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={config.tooltip}
data-testid="status-claude-code"
onMouseEnter={renderState.onMouseEnter}
onMouseLeave={renderState.onMouseLeave}
testId="status-claude-code"
className={config.missing ? 'text-[var(--accent-orange)]' : undefined}
>
<Terminal size={13} />
{config.label}
{renderState.warningIcon}
</span>
<span style={ICON_STYLE}>
<Terminal size={13} />
{config.label}
{config.missing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</StatusBarAction>
</>
)
}

View File

@@ -5,6 +5,7 @@ import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, SyncStatus } from '../../types'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
import { Button } from '@/components/ui/button'
import {
@@ -22,6 +23,12 @@ import { DISABLED_STYLE, ICON_STYLE, SEP_STYLE } from './styles'
import type { VaultOption } from './types'
import { VaultMenu } from './VaultMenu'
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
const ZOOM_RESET_TOOLTIP = { label: 'Reset the zoom level', shortcut: '⌘0' } as const
const FEEDBACK_TOOLTIP = { label: 'Share feedback' } as const
const NOTIFICATIONS_TOOLTIP = { label: 'Notifications are coming soon' } as const
const SETTINGS_TOOLTIP = { label: 'Open settings', shortcut: '⌘,' } as const
interface StatusBarPrimarySectionProps {
modifiedCount: number
vaultPath: string
@@ -109,18 +116,23 @@ export function StatusBarPrimarySection({
onRemoveVault={onRemoveVault}
/>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onCheckForUpdates}
style={{ ...ICON_STYLE, cursor: onCheckForUpdates ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Check for updates"
data-testid="status-build-number"
onMouseEnter={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
>
<Package size={13} />
{buildNumber ?? 'b?'}
</span>
<ActionTooltip copy={UPDATE_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onCheckForUpdates}
aria-label={UPDATE_TOOLTIP.label}
aria-disabled={onCheckForUpdates ? undefined : true}
data-testid="status-build-number"
>
<span style={ICON_STYLE}>
<Package size={13} />
{buildNumber ?? 'b?'}
</span>
</Button>
</ActionTooltip>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
@@ -163,45 +175,54 @@ export function StatusBarSecondarySection({
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{zoomLevel === 100 ? null : (
<span
role="button"
onClick={onZoomReset}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Reset zoom (⌘0)"
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-zoom"
>
{zoomLevel}%
</span>
<ActionTooltip copy={ZOOM_RESET_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onZoomReset}
aria-label={ZOOM_RESET_TOOLTIP.label}
data-testid="status-zoom"
>
<span style={ICON_STYLE}>{zoomLevel}%</span>
</Button>
</ActionTooltip>
)}
{onOpenFeedback && (
<ActionTooltip copy={FEEDBACK_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
aria-label={FEEDBACK_TOOLTIP.label}
data-testid="status-feedback"
>
<Megaphone size={14} />
Feedback
</Button>
</ActionTooltip>
)}
<ActionTooltip copy={NOTIFICATIONS_TOOLTIP} side="top">
<span style={DISABLED_STYLE} aria-label={NOTIFICATIONS_TOOLTIP.label}>
<Bell size={14} />
</span>
</ActionTooltip>
<ActionTooltip copy={SETTINGS_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
title="Share feedback"
data-testid="status-feedback"
size="icon-xs"
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onOpenSettings}
aria-label={SETTINGS_TOOLTIP.label}
data-testid="status-settings"
>
<Megaphone size={14} />
Feedback
<Settings size={14} />
</Button>
)}
<span style={DISABLED_STYLE} title="Coming soon">
<Bell size={14} />
</span>
<span
role="button"
onClick={onOpenSettings}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Settings (⌘,)"
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
>
<Settings size={14} />
</span>
</ActionTooltip>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
import { useDismissibleLayer } from './useDismissibleLayer'
@@ -204,23 +206,22 @@ export function VaultMenu({
return (
<div ref={menuRef} style={{ position: 'relative' }}>
<span
role="button"
onClick={() => setOpen((value) => !value)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
padding: '2px 4px',
borderRadius: 3,
background: open ? 'var(--hover)' : 'transparent',
}}
title="Switch vault"
>
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</span>
<ActionTooltip copy={{ label: 'Switch vault' }} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={open
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'}
onClick={() => setOpen((value) => !value)}
aria-label="Switch vault"
data-testid="status-vault-trigger"
>
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</Button>
</ActionTooltip>
{open && (
<div
style={{

View File

@@ -0,0 +1,40 @@
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export interface ActionTooltipCopy {
label: string
shortcut?: string
}
interface ActionTooltipProps {
copy: ActionTooltipCopy
children: ReactNode
className?: string
side?: ComponentProps<typeof TooltipContent>['side']
sideOffset?: number
}
export function ActionTooltip({
copy,
children,
className,
side = 'top',
sideOffset = 6,
}: ActionTooltipProps) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} sideOffset={sideOffset} className={cn('px-2.5 py-2', className)}>
<div className="flex min-w-0 items-center gap-3">
<span className="min-w-0 flex-1 text-[11px] font-medium leading-tight">{copy.label}</span>
{copy.shortcut && (
<span className="shrink-0 rounded border border-background/20 bg-background/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-background/80">
{copy.shortcut}
</span>
)}
</div>
</TooltipContent>
</Tooltip>
)
}