refactor: extract panel registry and pane context from workspace screen

Replace hardcoded renderContent() switch and per-kind descriptor logic
with a registry-based panel interface. Each panel type (agent, draft,
terminal, file) now self-registers with its component, useDescriptor
hook, and optional confirmClose. Panels access workspace context via
usePaneContext() instead of prop drilling. This prepares the workspace
screen for split pane support.
This commit is contained in:
Mohamed Boudra
2026-03-17 21:58:11 +07:00
parent abc8ad3fd4
commit 30dd54a318
17 changed files with 1105 additions and 817 deletions
+85
View File
@@ -0,0 +1,85 @@
import { Bot } from "lucide-react-native";
import invariant from "tiny-invariant";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
}
if (provider === "codex") {
return "Codex";
}
if (!provider) {
return "Agent";
}
return provider.charAt(0).toUpperCase() + provider.slice(1);
}
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
if (typeof title !== "string") {
return null;
}
const normalized = title.trim();
if (!normalized) {
return null;
}
if (normalized.toLowerCase() === "new agent") {
return null;
}
return normalized;
}
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string }
): PanelDescriptor {
const agent = useSessionStore(
(state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null
);
const provider = agent?.provider ?? "codex";
const label = resolveWorkspaceAgentTabLabel(agent?.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: agent
? deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
})
: null,
};
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
/>
);
}
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
};
+56
View File
@@ -0,0 +1,56 @@
import { Pencil } from "lucide-react-native";
import invariant from "tiny-invariant";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
function useDraftPanelDescriptor() {
return {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready" as const,
icon: Pencil,
statusBucket: null,
};
}
function DraftPanel() {
const {
serverId,
workspaceId,
tabId,
target,
openFileInWorkspace,
retargetCurrentTab,
} = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
onCreated={(agentSnapshot) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
useSessionStore.getState().setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, normalized);
return next;
});
}}
/>
);
}
export const draftPanelRegistration: PanelRegistration<"draft"> = {
kind: "draft",
component: DraftPanel,
useDescriptor: useDraftPanelDescriptor,
};
+34
View File
@@ -0,0 +1,34 @@
import { FileText } from "lucide-react-native";
import invariant from "tiny-invariant";
import { FilePane } from "@/components/file-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
return {
label: fileName,
subtitle: target.path,
titleState: "ready" as const,
icon: FileText,
statusBucket: null,
};
}
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "file", "FilePanel requires file target");
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceId}
filePath={target.path}
/>
);
}
export const filePanelRegistration: PanelRegistration<"file"> = {
kind: "file",
component: FilePanel,
useDescriptor: useFilePanelDescriptor,
};
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
const PaneContext = createContext<PaneContextValue | null>(null);
export function PaneProvider({
value,
children,
}: {
value: PaneContextValue;
children: ReactNode;
}) {
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
}
export function usePaneContext(): PaneContextValue {
const value = useContext(PaneContext);
invariant(value, "PaneContext is required");
return value;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ComponentType } from "react";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export interface PanelIconProps {
size: number;
color: string;
}
export interface PanelDescriptor {
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: ComponentType<PanelIconProps>;
statusBucket: SidebarStateBucket | null;
}
export interface PanelDescriptorContext {
serverId: string;
workspaceId: string;
}
export interface PanelRegistration<
K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"],
> {
kind: K;
component: ComponentType;
useDescriptor(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext
): Promise<boolean>;
}
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
export function registerPanel<K extends WorkspaceTabTarget["kind"]>(
registration: PanelRegistration<K>
): void {
panelRegistry.set(registration.kind, registration as unknown as PanelRegistration);
}
export function getPanelRegistration(
kind: WorkspaceTabTarget["kind"]
): PanelRegistration | undefined {
return panelRegistry.get(kind);
}
@@ -0,0 +1,18 @@
import { agentPanelRegistration } from "@/panels/agent-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
let panelsRegistered = false;
export function ensurePanelsRegistered(): void {
if (panelsRegistered) {
return;
}
registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration);
panelsRegistered = true;
}
@@ -0,0 +1,80 @@
import { useQuery } from "@tanstack/react-query";
import { Terminal } from "lucide-react-native";
import { View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
type ListTerminalsPayload = ListTerminalsResponse["payload"];
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function useTerminalPanelDescriptor(
target: { kind: "terminal"; terminalId: string },
context: { serverId: string; workspaceId: string }
): PanelDescriptor {
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const terminalsQuery = useQuery({
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
enabled: Boolean(client && context.workspaceId),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client) {
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
}
return client.listTerminals(context.workspaceId);
},
staleTime: 5_000,
});
const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
return {
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: Terminal,
statusBucket: null,
};
}
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
return <View style={{ flex: 1 }} />;
}
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (!terminalId) {
return;
}
openTab({ kind: "terminal", terminalId });
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
kind: "terminal",
component: TerminalPanel,
useDescriptor: useTerminalPanelDescriptor,
};