refactor(ui): redesign ProxyStatusWidget with two-state UX

- Replace 4-button row with 3 icon buttons (restart, stop, settings)
- Add tooltips to all icon buttons via TooltipProvider
- Move version display to header next to title
- Implement collapsed/expanded two-state design
- Remove manual version input field (use dropdown only)
- Add clickable update/downgrade badge showing target version
- Add "Version Management" section header in expanded view
- Increase control sizes (h-8) for better touch targets
- Settings icon toggles to X when expanded
This commit is contained in:
kaitranntt committed 2026-01-05 13:49:24 -05:00
1 parent 48d4a96a62
commit 8072b93b3b
1 file changed
+172 -177
@@ -4,6 +4,8 @@
* Displays CLIProxy process status with start/stop/restart controls. * Displays CLIProxy process status with start/stop/restart controls.
* Shows: running state, port, session count, uptime, update availability. * Shows: running state, port, session count, uptime, update availability.
* In remote mode: shows remote server info instead of local controls. * In remote mode: shows remote server info instead of local controls.
*
* Design: Two-state widget (collapsed/expanded) with icon-only control buttons.
*/ */
import { useState } from 'react'; import { useState } from 'react';
@@ -20,10 +22,11 @@ import {
Globe, Globe,
AlertTriangle, AlertTriangle,
Settings, Settings,
X,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { import {
Select, Select,
@@ -42,6 +45,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { api, type CliproxyServerConfig } from '@/lib/api-client';
import { import {
@@ -92,6 +96,53 @@ function formatTimeAgo(timestamp?: number): string {
return `${hours}h ago`; return `${hours}h ago`;
} }
/** Icon button with tooltip wrapper */
function IconButton({
icon: Icon,
tooltip,
onClick,
disabled,
isPending,
className,
variant = 'ghost',
}: {
icon: React.ElementType;
tooltip: string;
onClick: () => void;
disabled?: boolean;
isPending?: boolean;
className?: string;
variant?: 'ghost' | 'outline' | 'destructive-ghost';
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={variant === 'destructive-ghost' ? 'ghost' : variant}
size="sm"
className={cn(
'h-7 w-7 p-0',
variant === 'destructive-ghost' &&
'hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30',
className
)}
onClick={onClick}
disabled={disabled}
>
{isPending ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
);
}
export function ProxyStatusWidget() { export function ProxyStatusWidget() {
const { data: status, isLoading } = useProxyStatus(); const { data: status, isLoading } = useProxyStatus();
const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: updateCheck } = useCliproxyUpdateCheck();
@@ -101,10 +152,9 @@ export function ProxyStatusWidget() {
const restartProxy = useRestartProxy(); const restartProxy = useRestartProxy();
const installVersion = useInstallVersion(); const installVersion = useInstallVersion();
// Version picker state // Version picker state (expanded section)
const [showVersionSettings, setShowVersionSettings] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>(''); const [selectedVersion, setSelectedVersion] = useState<string>('');
const [manualVersion, setManualVersion] = useState('');
// Confirmation dialog state for unstable versions // Confirmation dialog state for unstable versions
const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [showUnstableConfirm, setShowUnstableConfirm] = useState(false);
@@ -129,6 +179,12 @@ export function ProxyStatusWidget() {
installVersion.isPending; installVersion.isPending;
const hasUpdate = updateCheck?.hasUpdate ?? false; const hasUpdate = updateCheck?.hasUpdate ?? false;
const isUnstable = updateCheck?.isStable === false; const isUnstable = updateCheck?.isStable === false;
const currentVersion = updateCheck?.currentVersion;
// Target version for update/downgrade badge
const targetVersion = isUnstable
? updateCheck?.maxStableVersion || versionsData?.latestStable
: updateCheck?.latestVersion;
// Handle version install (shows confirmation for unstable) // Handle version install (shows confirmation for unstable)
const handleInstallVersion = (version: string) => { const handleInstallVersion = (version: string) => {
@@ -207,17 +263,19 @@ export function ProxyStatusWidget() {
); );
} }
// Local mode: show original controls // Local mode: Two-state widget (collapsed/expanded)
return ( return (
<TooltipProvider delayDuration={300}>
<div <div
className={cn( className={cn(
'rounded-lg border p-3 transition-colors', 'rounded-lg border p-3 transition-colors',
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30' isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
)} )}
> >
{/* Header row: Status dot, title, version, update badge, icon buttons */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Status indicator */}
<div <div
className={cn( className={cn(
'w-2 h-2 rounded-full', 'w-2 h-2 rounded-full',
@@ -225,31 +283,84 @@ export function ProxyStatusWidget() {
)} )}
/> />
<span className="text-sm font-medium">CLIProxy Plus</span> <span className="text-sm font-medium">CLIProxy Plus</span>
{hasUpdate && (
{/* Version in header */}
{currentVersion && (
<span
className={cn(
'text-xs font-mono text-muted-foreground',
isUnstable && 'text-amber-600 dark:text-amber-400'
)}
>
v{currentVersion}
</span>
)}
{/* Clickable Update/Downgrade badge */}
{(hasUpdate || isUnstable) && targetVersion && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" className={cn(
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`} 'text-[10px] h-5 px-2 gap-1 cursor-pointer transition-colors',
isUnstable
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
)}
onClick={() => handleInstallVersion(targetVersion)}
title={`Click to ${isUnstable ? 'downgrade' : 'update'}`}
> >
{isUnstable ? (
<>
<ArrowDown className="w-2.5 h-2.5" />
{targetVersion}
</>
) : (
<>
<ArrowUp className="w-2.5 h-2.5" /> <ArrowUp className="w-2.5 h-2.5" />
Update {targetVersion}
</>
)}
</Badge> </Badge>
)} )}
</div> </div>
{/* Right side: status icon + control buttons when running */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isLoading ? ( {isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" /> <RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? ( ) : isRunning ? (
<Activity className="w-3 h-3 text-green-600" /> <>
{/* Icon buttons: Restart, Stop, Settings/Close */}
<IconButton
icon={RotateCw}
tooltip="Restart"
onClick={() => restartProxy.mutate()}
disabled={isActioning}
isPending={restartProxy.isPending}
/>
<IconButton
icon={Square}
tooltip="Stop"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
isPending={stopProxy.isPending}
variant="destructive-ghost"
/>
<IconButton
icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'}
onClick={() => setIsExpanded(!isExpanded)}
className={isExpanded ? 'bg-muted' : undefined}
/>
</>
) : ( ) : (
<Power className="w-3 h-3 text-muted-foreground" /> <Power className="w-3 h-3 text-muted-foreground" />
)} )}
</div> </div>
</div> </div>
{isRunning && status ? ( {/* Stats row when running */}
<> {isRunning && status && (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground"> <div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span> <span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && ( {status.sessionCount !== undefined && status.sessionCount > 0 && (
@@ -265,179 +376,87 @@ export function ProxyStatusWidget() {
</span> </span>
)} )}
</div> </div>
{/* Control buttons when running: Restart | Update/Downgrade | Stop | Settings */}
<div className="mt-2 flex items-center gap-2">
{/* Restart button - pure restart, no version change */}
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => restartProxy.mutate()}
disabled={isActioning}
title="Restart CLIProxy service (no version change)"
>
{restartProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<RotateCw className="w-3 h-3" />
)} )}
Restart
</Button>
{/* Update/Downgrade button - version change */} {/* Expanded section: Version Management */}
<Button {isRunning && (
variant={hasUpdate || isUnstable ? 'default' : 'outline'} <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
size="sm" <CollapsibleContent className="mt-3 pt-3 border-t border-muted">
className={cn( {/* Section header */}
'h-7 text-xs gap-1 flex-1', <h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4>
isUnstable
? 'bg-amber-500 hover:bg-amber-600 text-white'
: hasUpdate &&
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
)}
onClick={() => {
const targetVersion = isUnstable
? updateCheck?.maxStableVersion || versionsData?.latestStable
: updateCheck?.latestVersion;
if (targetVersion) handleInstallVersion(targetVersion);
}}
disabled={isActioning || (!hasUpdate && !isUnstable)}
title={
isUnstable
? `Downgrade to stable v${updateCheck?.maxStableVersion}`
: hasUpdate
? `Update to v${updateCheck?.latestVersion}`
: 'Already on latest version'
}
>
{isActioning && !restartProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : isUnstable ? (
<AlertTriangle className="w-3 h-3" />
) : hasUpdate ? (
<ArrowUp className="w-3 h-3" />
) : null}
{isUnstable ? 'Downgrade' : hasUpdate ? 'Update' : 'Latest'}
</Button>
{/* Stop button */}
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
title="Stop CLIProxy service"
>
{stopProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Square className="w-3 h-3" />
)}
Stop
</Button>
{/* Settings gear - toggle version picker */}
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0', showVersionSettings && 'bg-muted')}
onClick={() => setShowVersionSettings(!showVersionSettings)}
title="Version settings"
>
<Settings className="w-3.5 h-3.5" />
</Button>
</div>
{/* Version Settings (collapsible) */}
<Collapsible open={showVersionSettings} onOpenChange={setShowVersionSettings}>
<CollapsibleContent className="mt-2 pt-2 border-t border-muted">
<div className="space-y-2">
{/* Current version */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Current:</span>
<span
className={cn('font-mono', isUnstable && 'text-amber-600 dark:text-amber-400')}
>
v{updateCheck?.currentVersion}
{isUnstable && ' (unstable)'}
</span>
</div>
{/* Version picker row */} {/* Version picker row */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Dropdown */} {/* Dropdown - full width, no truncation */}
<Select <Select
value={selectedVersion} value={selectedVersion}
onValueChange={(v) => { onValueChange={setSelectedVersion}
setSelectedVersion(v);
setManualVersion('');
}}
disabled={versionsLoading} disabled={versionsLoading}
> >
<SelectTrigger className="h-7 text-xs flex-1"> <SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version..." /> <SelectValue placeholder="Select version to install..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => ( {versionsData?.versions.slice(0, 20).map((v) => {
const vIsUnstable =
versionsData?.maxStableVersion &&
isNewerVersionClient(v, versionsData.maxStableVersion);
return (
<SelectItem key={v} value={v} className="text-xs"> <SelectItem key={v} value={v} className="text-xs">
<span className="flex items-center gap-2">
v{v} v{v}
{v === versionsData.latestStable && ' (stable)'} {v === versionsData.latestStable && (
{v === versionsData.latest && <span className="text-green-600 dark:text-green-400">(stable)</span>
v !== versionsData.latestStable && )}
' (latest)'} {vIsUnstable && (
<span className="text-amber-600 dark:text-amber-400">⚠</span>
)}
</span>
</SelectItem> </SelectItem>
))} );
})}
</SelectContent> </SelectContent>
</Select> </Select>
{/* Manual input */}
<Input
placeholder="Manual..."
value={manualVersion}
onChange={(e) => {
setManualVersion(e.target.value);
setSelectedVersion('');
}}
className="h-7 text-xs w-24"
/>
{/* Install button */} {/* Install button */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs gap-1" className="h-8 text-xs gap-1.5 px-3"
onClick={() => handleInstallVersion(manualVersion || selectedVersion)} onClick={() => handleInstallVersion(selectedVersion)}
disabled={installVersion.isPending || (!selectedVersion && !manualVersion)} disabled={installVersion.isPending || !selectedVersion}
> >
{installVersion.isPending ? ( {installVersion.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" /> <RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : ( ) : (
<ArrowDown className="w-3 h-3" /> <Download className="w-3.5 h-3.5" />
)} )}
Install Install
</Button> </Button>
</div> </div>
{/* Stability warning */} {/* Stability warning for selected version */}
{(selectedVersion || manualVersion) && {selectedVersion &&
versionsData && versionsData?.maxStableVersion &&
isNewerVersionClient( isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
manualVersion || selectedVersion, <div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
versionsData.maxStableVersion <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
) && ( <span>Versions above {versionsData.maxStableVersion} have known issues</span>
<div className="flex items-center gap-1.5 text-[10px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" />
<span>
Versions above {versionsData.maxStableVersion} have known stability issues
</span>
</div> </div>
)} )}
{/* Sync time */}
{updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)}
</div> </div>
)}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</> )}
) : (
{/* Not running state */}
{!isRunning && (
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span> <span className="text-xs text-muted-foreground">Not running</span>
<Button <Button
@@ -457,31 +476,6 @@ export function ProxyStatusWidget() {
</div> </div>
)} )}
{/* Version sync indicator */}
{updateCheck?.currentVersion && (
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
<span className="flex items-center gap-1">
{isUnstable && (
<AlertTriangle
className="w-3 h-3 text-amber-500"
title={updateCheck.stabilityMessage}
/>
)}
<span className={isUnstable ? 'text-amber-600 dark:text-amber-400' : ''}>
v{updateCheck.currentVersion}
</span>
{isUnstable && (
<span className="text-amber-600/70 dark:text-amber-400/70">(unstable)</span>
)}
</span>
{updateCheck.checkedAt && (
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
Synced {formatTimeAgo(updateCheck.checkedAt)}
</span>
)}
</div>
)}
{/* Unstable Version Confirmation Dialog */} {/* Unstable Version Confirmation Dialog */}
<AlertDialog open={showUnstableConfirm} onOpenChange={setShowUnstableConfirm}> <AlertDialog open={showUnstableConfirm} onOpenChange={setShowUnstableConfirm}>
<AlertDialogContent> <AlertDialogContent>
@@ -514,5 +508,6 @@ export function ProxyStatusWidget() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</TooltipProvider>
); );
} }