agent-ecosystem/src/renderer/components/common/WorkspaceIndicator.tsx
matt b200561ac1 feat(ui): introduce WorkspaceIndicator component for workspace management
- Added a new `WorkspaceIndicator` component to display the active workspace and connection status.
- Integrated the `WorkspaceIndicator` into the `TabbedLayout` for improved workspace switching visibility.
- Removed the previous `ContextSwitcher` from `SidebarHeader` to streamline the UI.
- Updated `ServiceContext` and `ServiceContextRegistry` for better type handling and imports.

This commit enhances the user experience by providing a dedicated workspace indicator, facilitating easier context switching.
2026-02-12 15:25:17 +09:00

183 lines
5.8 KiB
TypeScript

/**
* WorkspaceIndicator - Floating bottom-right pill badge for workspace switching.
*
* Shows active workspace (Local or SSH host) with connection status badge.
* Clicking opens an upward dropdown to switch between available workspaces.
* Only renders when multiple contexts are available (hidden in local-only mode).
*/
import { useEffect, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { Check, ChevronDown } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
export const WorkspaceIndicator = (): React.JSX.Element | null => {
const { activeContextId, isContextSwitching, availableContexts, switchContext } = useStore(
useShallow((s) => ({
activeContextId: s.activeContextId,
isContextSwitching: s.isContextSwitching,
availableContexts: s.availableContexts,
switchContext: s.switchContext,
}))
);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close dropdown on Escape key
useEffect(() => {
function handleEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') {
setIsOpen(false);
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
// Only show when multiple contexts exist
if (availableContexts.length <= 1) return null;
const getContextLabel = (contextId: string): string => {
if (contextId === 'local') return 'Local';
return contextId.startsWith('ssh-') ? contextId.slice(4) : contextId;
};
const activeLabel = getContextLabel(activeContextId);
return (
<div ref={dropdownRef} className="fixed bottom-4 right-4 z-30">
{/* Trigger pill */}
<button
onClick={() => !isContextSwitching && setIsOpen(!isOpen)}
disabled={isContextSwitching}
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-xs shadow-lg transition-opacity hover:opacity-90 ${isContextSwitching ? 'opacity-50' : ''}`}
style={{
backgroundColor: 'var(--color-surface-raised)',
border: '1px solid var(--color-border-emphasis)',
}}
>
<ConnectionStatusBadge contextId={activeContextId} />
<span
className="font-medium"
style={{ color: isContextSwitching ? 'var(--color-text-muted)' : 'var(--color-text)' }}
>
{activeLabel}
</span>
<ChevronDown
className={`size-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{/* Upward dropdown */}
{isOpen && !isContextSwitching && (
<>
{/* Backdrop */}
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown content - opens upward */}
<div
className="absolute bottom-full right-0 z-20 mb-2 max-h-[250px] w-56 overflow-y-auto rounded-lg py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
{/* Header */}
<div
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Workspace
</div>
{/* Context list */}
{availableContexts.map((ctx) => {
const isSelected = ctx.id === activeContextId;
const label = getContextLabel(ctx.id);
return (
<ContextItem
key={ctx.id}
contextId={ctx.id}
label={label}
isSelected={isSelected}
onSelect={() => {
void switchContext(ctx.id);
setIsOpen(false);
}}
/>
);
})}
</div>
</>
)}
</div>
);
};
/**
* Individual context item in the dropdown.
*/
interface ContextItemProps {
contextId: string;
label: string;
isSelected: boolean;
onSelect: () => void;
}
const ContextItem = ({
contextId,
label,
isSelected,
onSelect,
}: Readonly<ContextItemProps>): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const buttonStyle: React.CSSProperties = isSelected
? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }
: {
backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',
opacity: isHovered ? 0.5 : 1,
};
return (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"
style={buttonStyle}
>
<ConnectionStatusBadge contextId={contextId} />
<span
className="flex-1 truncate text-sm"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{label}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};