agent-ecosystem/.planning/phases/04-workspace-ui/04-01-PLAN.md
matt fa62433219 docs(04): create workspace UI phase plans
Plan 01: ContextSwitcher dropdown, ConnectionStatusBadge, SidebarHeader
integration, keyboard shortcut (Cmd+Shift+K), and availableContexts state.
Plan 02: WorkspaceSection settings for SSH profile CRUD with auto-refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 01:59:55 +00:00

302 lines
13 KiB
Markdown

---
phase: 04-workspace-ui
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/renderer/components/common/ContextSwitcher.tsx
- src/renderer/components/common/ConnectionStatusBadge.tsx
- src/renderer/components/layout/SidebarHeader.tsx
- src/renderer/hooks/useKeyboardShortcuts.ts
- src/renderer/store/slices/contextSlice.ts
- src/renderer/App.tsx
autonomous: true
must_haves:
truths:
- "User sees context switcher in SidebarHeader Row 1 listing Local + all connected SSH workspaces"
- "Each workspace item shows connection status icon (connected/connecting/disconnected/error with distinct colors)"
- "User can click a workspace in the dropdown to switch contexts (triggers switchContext from contextSlice)"
- "User can press Cmd/Ctrl+Shift+K to cycle to the next context"
- "Context switcher is disabled while isContextSwitching is true (prevents race condition)"
- "Available contexts refresh on mount and when SSH status changes"
artifacts:
- path: "src/renderer/components/common/ContextSwitcher.tsx"
provides: "Dropdown listing local + SSH contexts with status badges, switch-on-click"
min_lines: 60
- path: "src/renderer/components/common/ConnectionStatusBadge.tsx"
provides: "Icon component rendering 4 connection states with distinct visual treatment"
min_lines: 25
- path: "src/renderer/components/layout/SidebarHeader.tsx"
provides: "Modified Row 1 with ContextSwitcher before project name"
- path: "src/renderer/hooks/useKeyboardShortcuts.ts"
provides: "Cmd+Shift+K shortcut for context cycling"
- path: "src/renderer/store/slices/contextSlice.ts"
provides: "availableContexts state + fetchAvailableContexts action"
key_links:
- from: "src/renderer/components/common/ContextSwitcher.tsx"
to: "src/renderer/store/slices/contextSlice.ts"
via: "useStore consuming availableContexts, activeContextId, switchContext, isContextSwitching"
pattern: "useStore.*availableContexts|switchContext"
- from: "src/renderer/components/common/ConnectionStatusBadge.tsx"
to: "src/renderer/store/slices/connectionSlice.ts"
via: "useStore consuming connectionState for SSH contexts"
pattern: "connectionState"
- from: "src/renderer/components/layout/SidebarHeader.tsx"
to: "src/renderer/components/common/ContextSwitcher.tsx"
via: "ContextSwitcher rendered in Row 1"
pattern: "<ContextSwitcher"
- from: "src/renderer/hooks/useKeyboardShortcuts.ts"
to: "src/renderer/store/slices/contextSlice.ts"
via: "Cmd+Shift+K triggers switchContext with next context from availableContexts"
pattern: "shiftKey.*switchContext|availableContexts"
---
<objective>
Create the context switcher UI and keyboard shortcuts for workspace switching.
Purpose: Users need a visible, always-accessible way to see which workspace (local or SSH) they are in, switch between workspaces, and understand connection status at a glance. This is the primary user-facing UI for the multi-context system built in Phases 1-3.
Output: ContextSwitcher dropdown in SidebarHeader, ConnectionStatusBadge icons, Cmd+Shift+K shortcut, and availableContexts state in contextSlice.
</objective>
<execution_context>
@/home/bskim/.claude/get-shit-done/workflows/execute-plan.md
@/home/bskim/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-workspace-ui/04-RESEARCH.md
@.planning/phases/03-state-management/03-01-SUMMARY.md
@src/renderer/components/layout/SidebarHeader.tsx
@src/renderer/hooks/useKeyboardShortcuts.ts
@src/renderer/store/slices/contextSlice.ts
@src/renderer/store/slices/connectionSlice.ts
@src/renderer/App.tsx
@src/renderer/components/settings/sections/ConnectionSection.tsx
@src/preload/index.ts
@src/shared/types/api.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ConnectionStatusBadge and ContextSwitcher components</name>
<files>
src/renderer/components/common/ConnectionStatusBadge.tsx
src/renderer/components/common/ContextSwitcher.tsx
src/renderer/store/slices/contextSlice.ts
</files>
<action>
**1. Add availableContexts state to contextSlice.ts:**
Add to `ContextSlice` interface:
- `availableContexts: ContextInfo[]` (import `ContextInfo` from `@shared/types/api`)
- `fetchAvailableContexts: () => Promise<void>`
Add to slice creator initial state:
- `availableContexts: [{ id: 'local', type: 'local' as const }]`
Implement `fetchAvailableContexts`:
- Call `window.electronAPI.context.list()`
- `set({ availableContexts: result })`
- Wrap in try/catch, on error fallback to `[{ id: 'local', type: 'local' }]`
Also call `fetchAvailableContexts()` inside `initializeContextSystem()` after setting `contextSnapshotsReady: true`.
**2. Create ConnectionStatusBadge.tsx:**
Small icon component that renders connection state visually:
```typescript
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
import { useStore } from '@renderer/store';
```
Props: `{ contextId: string; className?: string }`
Logic:
- If `contextId === 'local'`: render `<Monitor className="size-3.5 text-text-muted" />`
- If SSH: read `connectionState` and `connectedHost` from store via `useStore`
- Determine if this specific SSH context matches the connected host: `contextId === 'ssh-' + connectedHost`
- If match: use `connectionState` value
- If no match: treat as `'disconnected'`
- Render by state:
- `'connected'`: `<Wifi className="size-3.5 text-green-400" />`
- `'connecting'`: `<Loader2 className="size-3.5 animate-spin text-text-muted" />`
- `'disconnected'`: `<WifiOff className="size-3.5 text-text-muted" />`
- `'error'`: `<WifiOff className="size-3.5 text-red-400" />`
Apply `className` prop to wrapper if provided.
**3. Create ContextSwitcher.tsx:**
Follow the SidebarHeader dropdown pattern exactly (useRef, outside click, escape key, `inset-x-4` dropdown positioning).
Props: none (reads everything from store).
Store selectors (via `useShallow`):
- `activeContextId`, `isContextSwitching`, `availableContexts`, `switchContext`
State:
- `isOpen: boolean` (dropdown visibility)
- `dropdownRef: useRef<HTMLDivElement>`
Effects:
- Close on outside click (same pattern as SidebarHeader line 266-283)
- Close on Escape key (same pattern as SidebarHeader line 286-295)
Render:
- Trigger button:
- `ConnectionStatusBadge` for active context
- Display label: `'Local'` for `contextId === 'local'`, strip `'ssh-'` prefix for SSH (e.g., `ssh-192.168.1.10` -> `192.168.1.10`)
- `ChevronDown` icon (rotate-180 when open)
- `disabled={isContextSwitching}` and `opacity-50` when switching
- `style={{ WebkitAppRegion: 'no-drag' }}` (sits in drag region)
- Dropdown panel (when `isOpen && !isContextSwitching`):
- Fixed backdrop overlay `className="fixed inset-0 z-10"` (same as SidebarHeader)
- Panel: `absolute inset-x-4 top-full z-20 mt-1 max-h-[250px] overflow-y-auto rounded-lg py-1 shadow-xl`
- Background: `var(--color-surface-sidebar)`, border: `var(--color-border)`
- Header label: "Switch Workspace" (same style as SidebarHeader "Switch Repository")
- For each context in `availableContexts`:
- Button with hover state (follow ProjectDropdownItem pattern)
- `ConnectionStatusBadge` icon
- Label text (Local or host name)
- `Check` icon if `ctx.id === activeContextId`
- onClick: `switchContext(ctx.id)` then `setIsOpen(false)`
Use theme CSS variables throughout (no hardcoded colors except green-400/red-400 for status).
</action>
<verify>
Run `pnpm typecheck` - zero errors. Verify ContextSwitcher.tsx, ConnectionStatusBadge.tsx, and updated contextSlice.ts exist with correct exports. Confirm `availableContexts` is in the ContextSlice interface.
</verify>
<done>
ContextSwitcher renders a dropdown listing Local + SSH contexts with status badges. ConnectionStatusBadge shows 4 distinct states. contextSlice tracks availableContexts fetched from context.list() IPC. All three files type-check cleanly.
</done>
</task>
<task type="auto">
<name>Task 2: Wire ContextSwitcher into SidebarHeader and add keyboard shortcut</name>
<files>
src/renderer/components/layout/SidebarHeader.tsx
src/renderer/hooks/useKeyboardShortcuts.ts
src/renderer/App.tsx
</files>
<action>
**1. Modify SidebarHeader.tsx - Add ContextSwitcher to Row 1:**
Import `ContextSwitcher` from `'../common/ContextSwitcher'`.
In the Row 1 div (the one with `height: HEADER_ROW1_HEIGHT`, `paddingLeft: var(--macos-traffic-light-padding-left)`), add `ContextSwitcher` as the FIRST child before the existing project name button:
```tsx
{/* ROW 1: Project Identity (Title Bar / Drag Region) */}
<div ref={projectDropdownRef} className="relative flex select-none items-center justify-between pr-2" ...>
{/* NEW: Context Switcher - workspace indicator */}
<ContextSwitcher />
{/* Existing: Project name dropdown button */}
<button onClick={() => setIsProjectDropdownOpen(...)} ...>
...
</button>
{/* Existing: Collapse sidebar button */}
<button onClick={toggleSidebar} ...>
...
</button>
</div>
```
The ContextSwitcher already has `WebkitAppRegion: 'no-drag'` on its button, and the Row 1 div is the drag region, so it integrates naturally.
Add a small visual separator between ContextSwitcher and project name. Use a `<div>` with `className="mx-1 h-4 w-px"` and `style={{ backgroundColor: 'var(--color-border)' }}` between them. This gives a subtle vertical line separating "workspace" from "project".
Ensure the Row 1 layout uses `gap-2` or appropriate spacing so the new element fits without overflow. The existing `justify-between` may need adjustment - change to `gap-2` and keep the collapse button at the right with `ml-auto`.
**2. Modify useKeyboardShortcuts.ts - Add Cmd+Shift+K:**
Add to useShallow selector:
- `availableContexts: s.availableContexts`
- `activeContextId: s.activeContextId`
- `switchContext: s.switchContext`
- `isContextSwitching: s.isContextSwitching`
IMPORTANT: The Cmd+Shift+K check MUST come BEFORE the existing Cmd+K check (line 209). Since both match `event.key === 'k'`, the shiftKey variant must be tested first:
```typescript
// Cmd+Shift+K: Cycle to next workspace context
if (event.key === 'k' && event.shiftKey) {
event.preventDefault();
if (!isContextSwitching && availableContexts.length > 1) {
const currentIndex = availableContexts.findIndex(c => c.id === activeContextId);
const nextIndex = (currentIndex + 1) % availableContexts.length;
void switchContext(availableContexts[nextIndex].id);
}
return;
}
// Cmd+K: Open command palette for global search (existing)
if (event.key === 'k') {
...
}
```
Add `availableContexts`, `activeContextId`, `switchContext`, `isContextSwitching` to the useEffect dependency array.
**3. Modify App.tsx - Refresh contexts on SSH status change:**
Add a useEffect that listens for SSH status changes and refreshes the available contexts list:
```typescript
// Refresh available contexts when SSH connection state changes
useEffect(() => {
if (!window.electronAPI.ssh?.onStatus) return;
const cleanup = window.electronAPI.ssh.onStatus(() => {
void useStore.getState().fetchAvailableContexts();
});
return cleanup;
}, []);
```
This ensures the ContextSwitcher dropdown always reflects current SSH connections. Place this after the existing context system initialization effect.
</action>
<verify>
Run `pnpm typecheck` - zero errors. Run `pnpm test` - all tests pass. Run `pnpm build` - production build succeeds. Verify SidebarHeader renders ContextSwitcher in Row 1. Verify useKeyboardShortcuts handles Cmd+Shift+K before Cmd+K.
</verify>
<done>
SidebarHeader Row 1 shows [ContextSwitcher | separator | ProjectName | CollapseBtn]. Cmd+Shift+K cycles through available contexts. SSH status changes trigger context list refresh. No regressions in existing tests or type checking.
</done>
</task>
</tasks>
<verification>
1. `pnpm typecheck` passes with zero errors
2. `pnpm test` passes with no regressions
3. `pnpm build` succeeds
4. ContextSwitcher.tsx exports a component that renders a dropdown
5. ConnectionStatusBadge.tsx exports a component with 4 visual states
6. SidebarHeader.tsx imports and renders ContextSwitcher in Row 1
7. useKeyboardShortcuts.ts checks Cmd+Shift+K BEFORE Cmd+K
8. contextSlice.ts has availableContexts state and fetchAvailableContexts action
9. App.tsx has SSH status listener that refreshes available contexts
</verification>
<success_criteria>
- Context switcher visible in sidebar header showing active workspace with status icon
- Dropdown lists Local + all connected SSH workspaces
- Connection status icons: Monitor (local), Wifi/green (connected), Spinner (connecting), WifiOff/muted (disconnected), WifiOff/red (error)
- Clicking a workspace triggers context switch with overlay
- Cmd+Shift+K cycles through available workspaces
- Switcher disabled during active context switch
- Available contexts refresh when SSH status changes
</success_criteria>
<output>
After completion, create `.planning/phases/04-workspace-ui/04-01-SUMMARY.md`
</output>