fix: reflow status bar at narrow widths
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user