fix: reflow status bar at narrow widths

This commit is contained in:
lucaronin
2026-04-25 21:59:10 +02:00
parent 6105c7527c
commit a3096c596f
5 changed files with 555 additions and 162 deletions

View File

@@ -21,6 +21,36 @@ const installedAiAgentsStatus = {
codex: { status: 'installed' as const, version: '0.37.0' },
}
const DEFAULT_WINDOW_WIDTH = 1280
function setWindowWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
})
}
function renderDenseStatusBar() {
return render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
onCommitPush={vi.fn()}
onClickPulse={vi.fn()}
onOpenFeedback={vi.fn()}
buildNumber="b281"
onCheckForUpdates={vi.fn()}
mcpStatus="not_installed"
claudeCodeStatus="missing"
/>
)
}
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
act(() => {
fireEvent.focus(trigger)
@@ -37,6 +67,7 @@ async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
describe('StatusBar', () => {
beforeEach(() => {
vi.clearAllMocks()
setWindowWidth(DEFAULT_WINDOW_WIDTH)
})
it('does not display the bottom-bar note count readout', () => {
@@ -308,6 +339,37 @@ describe('StatusBar', () => {
expect(screen.getByText('3')).toBeInTheDocument()
})
it('wraps the bottom bar before hiding labels at medium widths', () => {
setWindowWidth(980)
renderDenseStatusBar()
expect(screen.getByTestId('status-bar')).toHaveStyle({
flexWrap: 'wrap',
height: 'auto',
})
expect(screen.getByText('Commit')).toBeInTheDocument()
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('Contribute')).toBeInTheDocument()
})
it('collapses status labels to icon-first controls at very narrow widths', () => {
setWindowWidth(760)
renderDenseStatusBar()
expect(screen.getByTestId('status-bar')).toHaveStyle({
flexWrap: 'wrap',
height: 'auto',
})
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.queryByText('Commit')).not.toBeInTheDocument()
expect(screen.queryByText('History')).not.toBeInTheDocument()
expect(screen.queryByText('Contribute')).not.toBeInTheDocument()
expect(screen.queryByText('No remote')).not.toBeInTheDocument()
expect(screen.queryByText('MCP')).not.toBeInTheDocument()
})
it('does not show Changes badge when modifiedCount is 0', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()

View File

@@ -14,6 +14,44 @@ import type { VaultOption } from './status-bar/types'
export type { VaultOption } from './status-bar/types'
const STACKED_STATUS_BAR_MAX_WIDTH = 1040
const COMPACT_STATUS_BAR_MAX_WIDTH = 820
function getWindowWidth() {
return typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth
}
function getStatusBarLayout(windowWidth: number) {
return {
compact: windowWidth <= COMPACT_STATUS_BAR_MAX_WIDTH,
stacked: windowWidth <= STACKED_STATUS_BAR_MAX_WIDTH,
}
}
function useStatusBarTicker() {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((tick) => tick + 1), 30_000)
return () => clearInterval(id)
}, [])
}
function useStatusBarLayout() {
const [windowWidth, setWindowWidth] = useState(() => getWindowWidth())
useEffect(() => {
if (typeof window === 'undefined') return
const handleResize = () => setWindowWidth(getWindowWidth())
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return getStatusBarLayout(windowWidth)
}
interface StatusBarProps {
noteCount: number
modifiedCount?: number
@@ -56,7 +94,12 @@ interface StatusBarProps {
claudeCodeVersion?: string | null
}
export function StatusBar({
interface StatusBarFooterProps extends StatusBarProps {
compact: boolean
stacked: boolean
}
function StatusBarFooter({
noteCount,
modifiedCount = 0,
vaultPath,
@@ -96,76 +139,89 @@ export function StatusBar({
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
}: StatusBarProps) {
const [, setTick] = useState(0)
compact,
stacked,
}: StatusBarFooterProps) {
return (
<footer
data-testid="status-bar"
style={{
minHeight: 30,
height: stacked ? 'auto' : 30,
flexShrink: 0,
display: 'flex',
flexWrap: stacked ? 'wrap' : 'nowrap',
alignItems: stacked ? 'flex-start' : 'center',
justifyContent: stacked ? 'flex-start' : 'space-between',
rowGap: stacked ? 4 : 0,
columnGap: 12,
background: 'var(--sidebar)',
borderTop: '1px solid var(--border)',
padding: stacked ? '4px 8px' : '0 8px',
fontSize: 11,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
}}
>
<StatusBarPrimarySection
modifiedCount={modifiedCount}
vaultPath={vaultPath}
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
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}
stacked={stacked}
compact={compact}
/>
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
stacked={stacked}
compact={compact}
/>
</footer>
)
}
useEffect(() => {
const id = setInterval(() => setTick((tick) => tick + 1), 30_000)
return () => clearInterval(id)
}, [])
export function StatusBar(props: StatusBarProps) {
useStatusBarTicker()
const { compact, stacked } = useStatusBarLayout()
return (
<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}
onCreateEmptyVault={onCreateEmptyVault}
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}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>
<StatusBarFooter {...props} compact={compact} stacked={stacked} />
</TooltipProvider>
)
}

View File

@@ -132,6 +132,7 @@ function StatusBarAction({
className,
style,
disabled = false,
compact = false,
}: {
copy: ActionTooltipCopy
children: ReactNode
@@ -141,6 +142,7 @@ function StatusBarAction({
className?: string
style?: CSSProperties
disabled?: boolean
compact?: boolean
}) {
return (
<ActionTooltip copy={copy} side="top">
@@ -150,6 +152,7 @@ function StatusBarAction({
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',
compact && 'h-6 gap-0.5 px-0.5',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
className,
)}
@@ -166,6 +169,11 @@ function StatusBarAction({
)
}
function StatusBarSeparator({ show = true }: { show?: boolean }) {
if (!show) return null
return <span style={SEP_STYLE}>|</span>
}
function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) {
if (!hasRemote(remoteStatus)) {
return <div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
@@ -301,12 +309,20 @@ export function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
export function OfflineBadge({
isOffline,
showSeparator = true,
compact = false,
}: {
isOffline?: boolean
showSeparator?: boolean
compact?: boolean
}) {
if (!isOffline) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<span
style={{
...ICON_STYLE,
@@ -322,7 +338,7 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
<span aria-hidden="true" style={{ fontSize: 10, lineHeight: 1 }}>
</span>
Offline
{compact ? null : 'Offline'}
</span>
</>
)
@@ -331,24 +347,29 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
export function NoRemoteBadge({
remoteStatus,
onAddRemote,
showSeparator = true,
compact = false,
}: {
remoteStatus?: GitRemoteStatus | null
onAddRemote?: () => void
showSeparator?: boolean
compact?: boolean
}) {
if (!isRemoteMissing(remoteStatus)) return null
if (onAddRemote) {
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: 'Add a remote to this vault' }}
onClick={onAddRemote}
testId="status-no-remote"
compact={compact}
>
<span style={ICON_STYLE}>
<GitBranch size={12} />
No remote
{compact ? null : 'No remote'}
</span>
</StatusBarAction>
</>
@@ -357,7 +378,7 @@ export function NoRemoteBadge({
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<span
style={{
...ICON_STYLE,
@@ -371,7 +392,7 @@ export function NoRemoteBadge({
data-testid="status-no-remote"
>
<GitBranch size={12} />
No remote
{compact ? null : 'No remote'}
</span>
</>
)
@@ -384,6 +405,7 @@ export function SyncBadge({
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
compact = false,
}: {
status: SyncStatus
lastSyncTime: number | null
@@ -391,6 +413,7 @@ export function SyncBadge({
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
compact?: boolean
}) {
const [showPopup, setShowPopup] = useState(false)
const popupRef = useRef<HTMLDivElement>(null)
@@ -415,10 +438,10 @@ export function SyncBadge({
return (
<div ref={popupRef} style={{ position: 'relative' }}>
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync">
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync" compact={compact}>
<span style={ICON_STYLE}>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
{formatSyncLabel(status, lastSyncTime)}
{compact ? null : formatSyncLabel(status, lastSyncTime)}
</span>
</StatusBarAction>
{showPopup && (
@@ -433,34 +456,55 @@ export function SyncBadge({
)
}
export function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) {
export function ConflictBadge({
count,
onClick,
showSeparator = true,
compact = false,
}: {
count: number
onClick?: () => void
showSeparator?: boolean
compact?: boolean
}) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: 'Resolve merge conflicts' }}
onClick={onClick}
testId="status-conflict-count"
className="text-[var(--destructive)]"
compact={compact}
>
<span style={ICON_STYLE}>
<AlertTriangle size={13} />
{count} conflict{count > 1 ? 's' : ''}
{compact ? null : `${count} conflict${count > 1 ? 's' : ''}`}
</span>
</StatusBarAction>
</>
)
}
export function ChangesBadge({ count, onClick }: { count: number; onClick?: () => void }) {
export function ChangesBadge({
count,
onClick,
showSeparator = true,
compact = false,
}: {
count: number
onClick?: () => void
showSeparator?: boolean
compact?: boolean
}) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count">
<StatusBarSeparator show={showSeparator} />
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count" compact={compact}>
<span style={ICON_STYLE}>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span
@@ -480,7 +524,7 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
>
{count}
</span>
Changes
{compact ? null : 'Changes'}
</span>
</StatusBarAction>
</>
@@ -490,60 +534,86 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
export function CommitButton({
onClick,
remoteStatus,
showSeparator = true,
compact = false,
}: {
onClick?: () => void
remoteStatus?: GitRemoteStatus | null
showSeparator?: boolean
compact?: boolean
}) {
if (!onClick) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push">
<StatusBarSeparator show={showSeparator} />
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push" compact={compact}>
<span style={ICON_STYLE}>
<GitCommitHorizontal size={13} />
Commit
{compact ? null : 'Commit'}
</span>
</StatusBarAction>
</>
)
}
export function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) {
export function PulseBadge({
onClick,
disabled,
showSeparator = true,
compact = false,
}: {
onClick?: () => void
disabled?: boolean
showSeparator?: boolean
compact?: boolean
}) {
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: disabled ? 'History is only available for git-enabled vaults' : 'Open change history' }}
onClick={disabled ? undefined : onClick}
testId="status-pulse"
disabled={Boolean(disabled)}
compact={compact}
>
<span style={ICON_STYLE}>
<Pulse size={13} />
History
{compact ? null : 'History'}
</span>
</StatusBarAction>
</>
)
}
export function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () => void }) {
export function McpBadge({
status,
onInstall,
showSeparator = true,
compact = false,
}: {
status: McpStatus
onInstall?: () => void
showSeparator?: boolean
compact?: boolean
}) {
const config = getMcpBadgeConfig(status, onInstall)
if (!config) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onClick}
testId="status-mcp"
className="text-[var(--accent-orange)]"
compact={compact}
>
<span style={ICON_STYLE}>
<Cpu size={13} />
MCP
{compact ? null : 'MCP'}
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</StatusBarAction>
@@ -551,22 +621,33 @@ export function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?:
)
}
export function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus; version?: string | null }) {
export function ClaudeCodeBadge({
status,
version,
showSeparator = true,
compact = false,
}: {
status: ClaudeCodeStatus
version?: string | null
showSeparator?: boolean
compact?: boolean
}) {
const config = getClaudeCodeBadgeConfig(status, version)
if (!config) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<StatusBarSeparator show={showSeparator} />
<StatusBarAction
copy={{ label: config.tooltip }}
onClick={config.onActivate}
testId="status-claude-code"
className={config.missing ? 'text-[var(--accent-orange)]' : undefined}
compact={compact}
>
<span style={ICON_STYLE}>
<Terminal size={13} />
{config.label}
{compact ? null : config.label}
{config.missing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</StatusBarAction>

View File

@@ -75,6 +75,8 @@ interface StatusBarPrimarySectionProps {
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
stacked?: boolean
compact?: boolean
}
interface StatusBarSecondarySectionProps {
@@ -85,6 +87,194 @@ interface StatusBarSecondarySectionProps {
onToggleThemeMode?: () => void
onOpenFeedback?: () => void
onOpenSettings?: () => void
stacked?: boolean
compact?: boolean
}
function BuildNumberButton({
buildNumber,
onCheckForUpdates,
compact,
}: {
buildNumber?: string
onCheckForUpdates?: () => void
compact: boolean
}) {
const className = compact
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
: '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'
return (
<ActionTooltip copy={UPDATE_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={className}
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>
)
}
function StatusBarAiBadge({
aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent,
onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
compact,
}: Pick<
StatusBarPrimarySectionProps,
| 'aiAgentsStatus'
| 'vaultAiGuidanceStatus'
| 'defaultAiAgent'
| 'onSetDefaultAiAgent'
| 'onRestoreVaultAiGuidance'
| 'claudeCodeStatus'
| 'claudeCodeVersion'
| 'compact'
>) {
if (aiAgentsStatus && defaultAiAgent) {
return (
<AiAgentsBadge
statuses={aiAgentsStatus}
guidanceStatus={vaultAiGuidanceStatus}
defaultAgent={defaultAiAgent}
onSetDefaultAgent={onSetDefaultAiAgent}
onRestoreGuidance={onRestoreVaultAiGuidance}
/>
)
}
if (!claudeCodeStatus) return null
return <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} showSeparator={!compact} compact={compact} />
}
function StatusBarPrimaryBadges({
modifiedCount,
visibleRemoteStatus,
onAddRemote,
onClickPending,
onCommitPush,
syncStatus,
lastSyncTime,
onTriggerSync,
onPullAndPush,
onOpenConflictResolver,
conflictCount,
onClickPulse,
isGitVault,
mcpStatus,
onInstallMcp,
aiAgentsStatus,
vaultAiGuidanceStatus,
defaultAiAgent,
onSetDefaultAiAgent,
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
isOffline,
compact,
}: {
modifiedCount: number
visibleRemoteStatus: GitRemoteStatus | null
onAddRemote: () => void
onClickPending?: () => void
onCommitPush?: () => void
syncStatus: SyncStatus
lastSyncTime: number | null
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
conflictCount: number
onClickPulse?: () => void
isGitVault: boolean
mcpStatus?: McpStatus
onInstallMcp?: () => void
aiAgentsStatus?: AiAgentsStatus
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
defaultAiAgent?: AiAgentId
onSetDefaultAiAgent?: (agent: AiAgentId) => void
onRestoreVaultAiGuidance?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
isOffline: boolean
compact: boolean
}) {
return (
<>
<OfflineBadge isOffline={isOffline} showSeparator={!compact} compact={compact} />
<NoRemoteBadge remoteStatus={visibleRemoteStatus} onAddRemote={onAddRemote} showSeparator={!compact} compact={compact} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} showSeparator={!compact} compact={compact} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} showSeparator={!compact} compact={compact} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}
remoteStatus={visibleRemoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
compact={compact}
/>
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} showSeparator={!compact} compact={compact} />
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} showSeparator={!compact} compact={compact} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} showSeparator={!compact} compact={compact} />}
<StatusBarAiBadge
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
compact={compact}
/>
</>
)
}
function FeedbackButton({
compact,
onOpenFeedback,
}: {
compact: boolean
onOpenFeedback: () => void
}) {
const className = compact
? 'h-6 w-6 rounded-sm p-0 text-muted-foreground hover:text-foreground'
: 'h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground'
return (
<ActionTooltip copy={FEEDBACK_TOOLTIP} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={className}
onClick={(event) => {
rememberFeedbackDialogOpener(event.currentTarget)
onOpenFeedback()
}}
aria-label={FEEDBACK_TOOLTIP.label}
data-testid="status-feedback"
>
<Megaphone size={14} />
{compact ? null : 'Contribute'}
</Button>
</ActionTooltip>
)
}
export function StatusBarPrimarySection({
@@ -121,6 +311,8 @@ export function StatusBarPrimarySection({
onRestoreVaultAiGuidance,
claudeCodeStatus,
claudeCodeVersion,
stacked = false,
compact = false,
}: StatusBarPrimarySectionProps) {
const {
openAddRemote,
@@ -136,7 +328,19 @@ export function StatusBarPrimarySection({
})
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: compact ? 8 : 12,
rowGap: stacked ? 4 : 0,
flex: 1,
minWidth: 0,
width: stacked ? '100%' : 'auto',
flexBasis: stacked ? '100%' : 'auto',
flexWrap: stacked ? 'wrap' : 'nowrap',
}}
>
<VaultMenu
vaults={vaults}
vaultPath={vaultPath}
@@ -146,56 +350,38 @@ export function StatusBarPrimarySection({
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
compact={compact}
/>
<span style={SEP_STYLE}>|</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={visibleRemoteStatus}
{compact ? null : <span style={SEP_STYLE}>|</span>}
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} />
<StatusBarPrimaryBadges
modifiedCount={modifiedCount}
visibleRemoteStatus={visibleRemoteStatus}
onAddRemote={() => {
void openAddRemote()
}}
/>
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} remoteStatus={visibleRemoteStatus} />
<SyncBadge
status={syncStatus}
onClickPending={onClickPending}
onCommitPush={onCommitPush}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}
remoteStatus={visibleRemoteStatus}
onTriggerSync={onTriggerSync}
onPullAndPush={onPullAndPush}
onOpenConflictResolver={onOpenConflictResolver}
conflictCount={conflictCount}
onClickPulse={onClickPulse}
isGitVault={isGitVault}
mcpStatus={mcpStatus}
onInstallMcp={onInstallMcp}
aiAgentsStatus={aiAgentsStatus}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
defaultAiAgent={defaultAiAgent}
onSetDefaultAiAgent={onSetDefaultAiAgent}
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
claudeCodeStatus={claudeCodeStatus}
claudeCodeVersion={claudeCodeVersion}
isOffline={isOffline}
compact={compact}
/>
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
{aiAgentsStatus && defaultAiAgent
? (
<AiAgentsBadge
statuses={aiAgentsStatus}
guidanceStatus={vaultAiGuidanceStatus}
defaultAgent={defaultAiAgent}
onSetDefaultAgent={onSetDefaultAiAgent}
onRestoreGuidance={onRestoreVaultAiGuidance}
/>
)
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
<AddRemoteModal
open={showAddRemote}
vaultPath={vaultPath}
@@ -214,13 +400,24 @@ export function StatusBarSecondarySection({
onToggleThemeMode,
onOpenFeedback,
onOpenSettings,
stacked = false,
compact = false,
}: StatusBarSecondarySectionProps) {
void noteCount
const ThemeIcon = themeMode === 'dark' ? Sun : Moon
const themeTooltip = themeMode === 'dark' ? LIGHT_MODE_TOOLTIP : DARK_MODE_TOOLTIP
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: stacked ? 'flex-end' : 'flex-start',
gap: compact ? 8 : 12,
flexShrink: 0,
width: stacked ? '100%' : 'auto',
}}
>
{zoomLevel === 100 ? null : (
<ActionTooltip copy={ZOOM_RESET_TOOLTIP} side="top">
<Button
@@ -236,25 +433,7 @@ export function StatusBarSecondarySection({
</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={(event) => {
rememberFeedbackDialogOpener(event.currentTarget)
onOpenFeedback()
}}
aria-label={FEEDBACK_TOOLTIP.label}
data-testid="status-feedback"
>
<Megaphone size={14} />
Contribute
</Button>
</ActionTooltip>
)}
{onOpenFeedback && <FeedbackButton compact={compact} onOpenFeedback={onOpenFeedback} />}
<ActionTooltip copy={themeTooltip} side="top">
<Button
type="button"

View File

@@ -15,6 +15,7 @@ interface VaultMenuProps {
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
compact?: boolean
}
interface VaultMenuItemProps {
@@ -42,6 +43,18 @@ interface VaultAction {
onClick: () => void
}
function getVaultTriggerClassName(open: boolean, compact: boolean) {
if (compact) {
return open
? 'h-6 w-6 rounded-sm bg-[var(--hover)] p-0 text-foreground hover:bg-[var(--hover)]'
: 'h-6 w-6 rounded-sm p-0 text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
}
return 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'
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
@@ -182,11 +195,15 @@ export function VaultMenu({
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
compact = false,
}: VaultMenuProps) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((vault) => vault.path === vaultPath)
const canRemove = !!onRemoveVault && vaults.length > 1
const triggerClassName = getVaultTriggerClassName(open, compact)
const triggerSize = compact ? 'icon-xs' : 'xs'
const activeVaultLabel = activeVault?.label ?? 'Vault'
useDismissibleLayer(open, menuRef, () => setOpen(false))
@@ -205,16 +222,14 @@ export function VaultMenu({
<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'}
size={triggerSize}
className={triggerClassName}
onClick={() => setOpen((value) => !value)}
aria-label="Switch vault"
data-testid="status-vault-trigger"
>
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
{compact ? null : <span className="max-w-32 truncate">{activeVaultLabel}</span>}
</Button>
</ActionTooltip>
{open && (