# Panel Interface Refactor Plan **Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels. ## The Problem The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in: - `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status) - `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators Every new panel type requires editing 3+ files. This must become a registry where panels self-register. ## Target Architecture ### 1. PanelRegistration Interface ```typescript // panels/panel-registry.ts interface PanelDescriptor { label: string; subtitle: string; titleState: "ready" | "loading"; icon: React.ComponentType<{ size: number; color: string }>; statusBucket: SidebarStateBucket | null; } interface PanelRegistration { kind: K; component: React.ComponentType; useDescriptor( target: Extract, context: { serverId: string; workspaceId: string }, ): PanelDescriptor; confirmClose?( target: Extract, context: { serverId: string; workspaceId: string }, ): Promise; } ``` ### 2. Panel Registry ```typescript const panelRegistry = new Map(); function registerPanel(registration: PanelRegistration): void { panelRegistry.set(registration.kind, registration); } function getPanelRegistration(kind: string): PanelRegistration | undefined { return panelRegistry.get(kind); } ``` ### 3. PaneContext Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props. ```typescript interface PaneContextValue { serverId: string; workspaceId: string; tabId: string; target: WorkspaceTabTarget; openTab(target: WorkspaceTabTarget): void; closeCurrentTab(): void; retargetCurrentTab(target: WorkspaceTabTarget): void; openFileInWorkspace(filePath: string): void; } ``` ### 4. WorkspaceTabTarget stays unchanged ```typescript type WorkspaceTabTarget = | { kind: "draft"; draftId: string } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string }; ``` No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target. ## Panel Implementations Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly. ### Agent Panel Example ```typescript // panels/agent-panel.ts function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { const agent = useSessionStore( (s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null, ); const provider = agent?.provider ?? "codex"; const label = resolveAgentLabel(agent?.title); return { label: label ?? "", subtitle: `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon: agentIconForProvider(provider), statusBucket: agent ? deriveAgentStatusBucket(agent) : null, }; } function AgentPanel() { const { serverId, target, openFileInWorkspace } = usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( ); } export const agentPanelRegistration: PanelRegistration<"agent"> = { kind: "agent", component: AgentPanel, useDescriptor: useAgentPanelDescriptor, async confirmClose(target, context) { const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId); if (agent?.status === "running") { return confirmDialog({ title: "Agent is still running. Close anyway?" }); } return true; }, }; ``` ### Terminal Panel Example ```typescript function useTerminalPanelDescriptor( target: { kind: "terminal"; terminalId: string }, _context: { serverId: string; workspaceId: string }, ): PanelDescriptor { // read terminal data from appropriate store return { label: "Terminal", subtitle: "Terminal", titleState: "ready", icon: TerminalIcon, statusBucket: null, }; } function TerminalPanel() { const { serverId, workspaceId, target, openTab } = usePaneContext(); invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); return ( { if (terminalId) { openTab({ kind: "terminal", terminalId }); } }} hideHeader manageTerminalDirectorySubscription={false} /> ); } ``` ### Draft Panel Example ```typescript function useDraftPanelDescriptor( _target: { kind: "draft"; draftId: string }, _context: { serverId: string; workspaceId: string }, ): PanelDescriptor { return { label: "New Agent", subtitle: "New Agent", titleState: "ready", icon: PencilIcon, statusBucket: null, }; } function DraftPanel() { const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext(); invariant(target.kind === "draft", "DraftPanel requires draft target"); return ( { retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }} /> ); } ``` ### File Panel Example ```typescript function useFilePanelDescriptor( target: { kind: "file"; path: string }, _context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path; return { label: fileName, subtitle: target.path, titleState: "ready", icon: FileTextIcon, statusBucket: null, }; } function FilePanel() { const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "file", "FilePanel requires file target"); return ( ); } ``` ## How the Tab Bar Uses It Each tab chip calls the panel's `useDescriptor` hook: ```typescript function TabChip({ tabId, target, serverId, workspaceId }: { tabId: string; target: WorkspaceTabTarget; serverId: string; workspaceId: string; }) { const registration = getPanelRegistration(target.kind); invariant(registration, `No panel registration for kind: ${target.kind}`); const descriptor = registration.useDescriptor(target, { serverId, workspaceId }); return ( } statusBucket={descriptor.statusBucket} /> ); } ``` ## How the Workspace Screen Renders Content Replaces the entire `renderContent()` switch: ```typescript function PaneContent({ tabId, target, serverId, workspaceId }: { tabId: string; target: WorkspaceTabTarget; serverId: string; workspaceId: string; }) { const registration = getPanelRegistration(target.kind); if (!registration) return null; const Component = registration.component; return ( ); } ``` ## Implementation Steps ### Step 1: Create panel registry infrastructure Create the following new files: - `packages/app/src/panels/panel-registry.ts` — `PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()` - `packages/app/src/panels/pane-context.ts` — `PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook ### Step 2: Create panel registration files Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules: - `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose` - `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor` - `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor` - `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor` - `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each ### Step 3: Refactor workspace-tab-model.ts Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`. Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution. ### Step 4: Refactor workspace-screen.tsx renderContent() Replace the `renderContent()` switch with `` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen. ### Step 5: Refactor workspace-tab-presentation.tsx Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor. ### Step 6: Verify - `npm run typecheck` must pass - All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus - Mobile tab switcher must work unchanged - No visual regressions in tab bar, icons, status indicators ## Constraints - **Pure refactor** — zero user-visible behavior changes - **No new features** — no splits, no new panel types, no new keyboard shortcuts - **WorkspaceTabTarget stays unchanged** — no store migration - **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor - **Do not create index.ts barrel files** — project convention - **Use `invariant` from `tiny-invariant`** for asserting panel target kinds - **Use `function` declarations** — project convention (no arrow function components) - **Use `interface` over `type` where possible** — project convention - **Run `npm run typecheck` after every change** — project rule