From a3096c596f00a3ab76720a1c922a8e033135f2e9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 25 Apr 2026 21:59:10 +0200 Subject: [PATCH] fix: reflow status bar at narrow widths --- src/components/StatusBar.test.tsx | 62 ++++ src/components/StatusBar.tsx | 190 +++++++---- src/components/status-bar/StatusBarBadges.tsx | 137 ++++++-- .../status-bar/StatusBarSections.tsx | 303 ++++++++++++++---- src/components/status-bar/VaultMenu.tsx | 25 +- 5 files changed, 555 insertions(+), 162 deletions(-) diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 1810e59c..d124cc60 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -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( + + ) +} + 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() expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument() diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 58503f45..3ddc44fe 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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 ( +
+ + +
+ ) +} - useEffect(() => { - const id = setInterval(() => setTick((tick) => tick + 1), 30_000) - return () => clearInterval(id) - }, []) +export function StatusBar(props: StatusBarProps) { + useStatusBarTicker() + const { compact, stacked } = useStatusBarLayout() return ( -
- - -
+
) } diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 8695bd23..154ba39b 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -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 ( @@ -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 | +} + function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) { if (!hasRemote(remoteStatus)) { return
No remote configured
@@ -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 ( <> - | + - Offline + {compact ? null : 'Offline'} ) @@ -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 ( <> - | + - No remote + {compact ? null : 'No remote'} @@ -357,7 +378,7 @@ export function NoRemoteBadge({ return ( <> - | + - No remote + {compact ? null : 'No remote'} ) @@ -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(null) @@ -415,10 +438,10 @@ export function SyncBadge({ return (
- + - {formatSyncLabel(status, lastSyncTime)} + {compact ? null : formatSyncLabel(status, lastSyncTime)} {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 ( <> - | + - {count} conflict{count > 1 ? 's' : ''} + {compact ? null : `${count} conflict${count > 1 ? 's' : ''}`} ) } -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 ( <> - | - + + {count} - Changes + {compact ? null : 'Changes'} @@ -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 ( <> - | - + + - Commit + {compact ? null : 'Commit'} ) } -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 ( <> - | + - History + {compact ? null : 'History'} ) } -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 ( <> - | + - MCP + {compact ? null : 'MCP'} @@ -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 ( <> - | + - {config.label} + {compact ? null : config.label} {config.missing && } diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index f792b2dd..bb3d1267 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -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 ( + + + + ) +} + +function StatusBarAiBadge({ + aiAgentsStatus, + vaultAiGuidanceStatus, + defaultAiAgent, + onSetDefaultAiAgent, + onRestoreVaultAiGuidance, + claudeCodeStatus, + claudeCodeVersion, + compact, +}: Pick< + StatusBarPrimarySectionProps, + | 'aiAgentsStatus' + | 'vaultAiGuidanceStatus' + | 'defaultAiAgent' + | 'onSetDefaultAiAgent' + | 'onRestoreVaultAiGuidance' + | 'claudeCodeStatus' + | 'claudeCodeVersion' + | 'compact' +>) { + if (aiAgentsStatus && defaultAiAgent) { + return ( + + ) + } + + if (!claudeCodeStatus) return null + + return +} + +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 ( + <> + + + + + + + + {mcpStatus && } + + + ) +} + +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 ( + + + + ) } 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 ( -
+
- | - - - - - |} + + { void openAddRemote() }} - /> - - - - - - {mcpStatus && } - {aiAgentsStatus && defaultAiAgent - ? ( - - ) - : claudeCodeStatus && } +
{zoomLevel === 100 ? null : ( - - )} + {onOpenFeedback && } {open && (