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,
};
@@ -10,11 +10,7 @@ function makeAgentTab(id: string): WorkspaceTabDescriptor {
key: `agent_${id}`, key: `agent_${id}`,
tabId: `agent_${id}`, tabId: `agent_${id}`,
kind: "agent", kind: "agent",
agentId: id, target: { kind: "agent", agentId: id },
provider: "codex",
label: `Agent ${id}`,
subtitle: "",
titleState: "ready",
}; };
} }
@@ -23,9 +19,7 @@ function makeTerminalTab(id: string): WorkspaceTabDescriptor {
key: `terminal_${id}`, key: `terminal_${id}`,
tabId: `terminal_${id}`, tabId: `terminal_${id}`,
kind: "terminal", kind: "terminal",
terminalId: id, target: { kind: "terminal", terminalId: id },
label: `Terminal ${id}`,
subtitle: "",
}; };
} }
@@ -34,9 +28,7 @@ function makeFileTab(path: string): WorkspaceTabDescriptor {
key: `file_${path}`, key: `file_${path}`,
tabId: `file_${path}`, tabId: `file_${path}`,
kind: "file", kind: "file",
filePath: path, target: { kind: "file", path },
label: path.split("/").pop() ?? path,
subtitle: path,
}; };
} }
@@ -14,12 +14,12 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
}; };
for (const tab of tabs) { for (const tab of tabs) {
if (tab.kind === "agent") { if (tab.target.kind === "agent") {
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId }); groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
continue; continue;
} }
if (tab.kind === "terminal") { if (tab.target.kind === "terminal") {
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId }); groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
continue; continue;
} }
groups.otherTabs.push({ tabId: tab.tabId }); groups.otherTabs.push({ tabId: tab.tabId });
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Plus, X } from "lucide-react-native"; import { Plus, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -14,13 +14,13 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import { import {
deriveWorkspaceTabPresentation, useWorkspaceTabPresentation,
WorkspaceTabIcon, WorkspaceTabIcon,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation"; } from "@/screens/workspace/workspace-tab-presentation";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { encodeFilePathForPathSegment } from "@/utils/host-routes"; import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import type { Agent } from "@/stores/session-store";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -29,8 +29,8 @@ type NewTabOptionId = "__new_tab_agent__";
type WorkspaceDesktopTabsRowProps = { type WorkspaceDesktopTabsRowProps = {
tabs: WorkspaceTabDescriptor[]; tabs: WorkspaceTabDescriptor[];
activeTabKey: string; activeTabKey: string;
agentsById: Map<string, Agent>;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string;
hoveredCloseTabKey: string | null; hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>; setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>; setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
@@ -49,17 +49,46 @@ type WorkspaceDesktopTabsRowProps = {
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
}; };
export function WorkspaceDesktopTabsRow({ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
tabs, if (tab.target.kind === "draft") {
activeTabKey, return "New Agent";
agentsById, }
if (tab.target.kind === "terminal") {
return "Terminal";
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
}
return "";
}
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "agent") {
return `workspace-agent-close-${tab.target.agentId}`;
}
if (tab.target.kind === "terminal") {
return `workspace-terminal-close-${tab.target.terminalId}`;
}
if (tab.target.kind === "draft") {
return `workspace-draft-close-${tab.target.draftId}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
function TabChip({
tab,
index,
tabCount,
isActive,
resolvedTabWidth,
showLabel,
showCloseButton,
isCloseHovered,
isClosingTab,
normalizedServerId, normalizedServerId,
hoveredCloseTabKey, normalizedWorkspaceId,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab, onNavigateTab,
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
@@ -67,108 +96,51 @@ export function WorkspaceDesktopTabsRow({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onPresentationChange,
newTabAgentOptionId, dragHandleProps,
onReorderTabs, }: {
}: WorkspaceDesktopTabsRowProps) { tab: WorkspaceTabDescriptor;
index: number;
tabCount: number;
isActive: boolean;
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
isCloseHovered: boolean;
isClosingTab: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
dragHandleProps: any;
}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const presentation = useWorkspaceTabPresentation({
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0); tab,
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const layoutMetrics = useMemo(
() => ({
rowHorizontalInset: 0,
actionsReservedWidth: Math.max(0, tabsActionsWidth),
rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1],
maxTabWidth: 200,
tabIconWidth: 14,
tabHorizontalPadding: theme.spacing[3],
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[tabsActionsWidth, theme.spacing]
);
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
if (tab.kind === "agent" && tab.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
}
return tab.label.length;
}),
[layoutMetrics.estimatedCharWidth, tabs]
);
const { layout } = useWorkspaceTabLayout({
tabLabelLengths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics,
});
return (
<View
style={styles.tabsContainer}
testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout}
>
<ScrollView
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={[
styles.tabsScroll,
layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent,
]}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId, serverId: normalizedServerId,
agentId: tab.agentId, workspaceId: normalizedWorkspaceId,
}); });
const isClosingTerminal =
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true;
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
const tooltipLabel =
tab.kind === "agent" && tab.titleState === "loading"
? "Loading agent title"
: presentation.label;
useEffect(() => {
onPresentationChange(tab.key, presentation);
}, [onPresentationChange, presentation, tab.key]);
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
const contextMenuTestId = `workspace-tab-context-${tab.key}`; const contextMenuTestId = `workspace-tab-context-${tab.key}`;
const menuEntries = buildWorkspaceTabMenuEntries({ const menuEntries = buildWorkspaceTabMenuEntries({
surface: "desktop", surface: "desktop",
tab, tab,
index, index,
tabCount: tabs.length, tabCount,
menuTestIDBase: contextMenuTestId, menuTestIDBase: contextMenuTestId,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
@@ -223,7 +195,7 @@ export function WorkspaceDesktopTabsRow({
<View <View
style={[ style={[
styles.tabLabelSkeleton, styles.tabLabelSkeleton,
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton, showCloseButton && styles.tabLabelSkeletonWithCloseButton,
]} ]}
/> />
) : ( ) : (
@@ -231,29 +203,21 @@ export function WorkspaceDesktopTabsRow({
style={[ style={[
styles.tabLabel, styles.tabLabel,
isActive && styles.tabLabelActive, isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton, showCloseButton && styles.tabLabelWithCloseButton,
]} ]}
selectable={false} selectable={false}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="tail" ellipsizeMode="tail"
> >
{tab.label} {presentation.label}
</Text> </Text>
) )
) : null} ) : null}
</View> </View>
{shouldShowCloseButton ? ( {showCloseButton ? (
<Pressable <Pressable
testID={ testID={getCloseButtonTestId(tab)}
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "draft"
? `workspace-draft-close-${tab.draftId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
disabled={isClosingTab} disabled={isClosingTab}
onHoverIn={() => { onHoverIn={() => {
setHoveredTabKey(tab.key); setHoveredTabKey(tab.key);
@@ -314,6 +278,184 @@ export function WorkspaceDesktopTabsRow({
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
}
export function WorkspaceDesktopTabsRow({
tabs,
activeTabKey,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onReorderTabs,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [presentationsByKey, setPresentationsByKey] = useState<Map<string, WorkspaceTabPresentation>>(
() => new Map()
);
const handlePresentationChange = useCallback(
(tabKey: string, presentation: WorkspaceTabPresentation) => {
setPresentationsByKey((current) => {
const existing = current.get(tabKey);
if (
existing?.label === presentation.label &&
existing?.subtitle === presentation.subtitle &&
existing?.titleState === presentation.titleState &&
existing?.statusBucket === presentation.statusBucket &&
existing?.icon === presentation.icon
) {
return current;
}
const next = new Map(current);
next.set(tabKey, presentation);
return next;
});
},
[]
);
useEffect(() => {
setPresentationsByKey((current) => {
const next = new Map<string, WorkspaceTabPresentation>();
for (const tab of tabs) {
const presentation = current.get(tab.key);
if (presentation) {
next.set(tab.key, presentation);
}
}
return next.size === current.size ? current : next;
});
}, [tabs]);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}, []);
const layoutMetrics = useMemo(
() => ({
rowHorizontalInset: 0,
actionsReservedWidth: Math.max(0, tabsActionsWidth),
rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1],
maxTabWidth: 200,
tabIconWidth: 14,
tabHorizontalPadding: theme.spacing[3],
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[tabsActionsWidth, theme.spacing]
);
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
const presentation = presentationsByKey.get(tab.key);
if (presentation?.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
}
const label = presentation?.label ?? getFallbackTabLabel(tab);
return label.length;
}),
[layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
);
const { layout } = useWorkspaceTabLayout({
tabLabelLengths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics,
});
return (
<View
style={styles.tabsContainer}
testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout}
>
<ScrollView
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={[
styles.tabsScroll,
layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent,
]}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.target.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.target.agentId,
});
const isClosingTerminal =
tab.target.kind === "terminal" &&
killTerminalPending &&
killTerminalId === tab.target.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true;
return (
<TabChip
key={tab.key}
tab={tab}
index={index}
tabCount={tabs.length}
isActive={isActive}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
isCloseHovered={isCloseHovered}
isClosingTab={isClosingTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onPresentationChange={handlePresentationChange}
dragHandleProps={dragHandleProps}
/>
);
}} }}
/> />
</ScrollView> </ScrollView>
@@ -22,6 +22,7 @@ import {
} from "lucide-react-native"; } from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
@@ -40,8 +41,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FilePane } from "@/components/file-pane";
import { TerminalPane } from "@/components/terminal-pane";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -70,19 +69,18 @@ import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
type CheckoutStatusPayload, type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query"; } from "@/hooks/use-checkout-status-query";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; import { PaneProvider } from "@/panels/pane-context";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import { import {
deriveWorkspaceTabPresentation, useWorkspaceTabPresentation,
type WorkspaceTabPresentation,
WorkspaceTabIcon, WorkspaceTabIcon,
WorkspaceTabOptionRow, WorkspaceTabOptionRow,
} from "@/screens/workspace/workspace-tab-presentation"; } from "@/screens/workspace/workspace-tab-presentation";
@@ -145,14 +143,40 @@ function buildOpenIntentKey(input: {
return `${input.serverId}:${input.workspaceId}:${openParam}`; return `${input.serverId}:${input.workspaceId}:${openParam}`;
} }
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
}
if (tab.target.kind === "terminal") {
return "Terminal";
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
}
return "Agent";
}
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
}
if (tab.target.kind === "agent") {
return "Agent";
}
if (tab.target.kind === "terminal") {
return "Terminal";
}
return tab.target.path;
}
type MobileWorkspaceTabSwitcherProps = { type MobileWorkspaceTabSwitcherProps = {
tabs: WorkspaceTabDescriptor[]; tabs: WorkspaceTabDescriptor[];
activeTabKey: string; activeTabKey: string;
activeTabLabel: string; activeTab: WorkspaceTabDescriptor | null;
activeTabPresentation: WorkspaceTabPresentation | null;
tabSwitcherOptions: ComboboxOption[]; tabSwitcherOptions: ComboboxOption[];
tabByKey: Map<string, WorkspaceTabDescriptor>; tabByKey: Map<string, WorkspaceTabDescriptor>;
tabPresentationsByKey: Map<string, WorkspaceTabPresentation>; normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void; onSelectSwitcherTab: (key: string) => void;
onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void; onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
@@ -163,120 +187,99 @@ type MobileWorkspaceTabSwitcherProps = {
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}; };
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ function MobileActiveTabTrigger({
tabs, activeTab,
activeTabKey, normalizedServerId,
activeTabLabel, normalizedWorkspaceId,
activeTabPresentation, }: {
tabSwitcherOptions, activeTab: WorkspaceTabDescriptor | null;
tabByKey, normalizedServerId: string;
tabPresentationsByKey, normalizedWorkspaceId: string;
onSelectSwitcherTab, }) {
onSelectNewTabOption, if (!activeTab) {
return null;
}
return (
<ResolvedMobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
function ResolvedMobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
}: {
activeTab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
const presentation = useWorkspaceTabPresentation({
tab: activeTab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
return (
<>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
<WorkspaceTabIcon presentation={presentation} active />
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
</Text>
</>
);
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
tabCount,
normalizedServerId,
normalizedWorkspaceId,
selected,
active,
onPress,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
onCloseTab, onCloseTab,
onCloseTabsAbove, onCloseTabsAbove,
onCloseTabsBelow, onCloseTabsBelow,
onCloseOtherTabs, onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) { }: {
tab: WorkspaceTabDescriptor;
tabIndex: number;
tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
selected: boolean;
active: boolean;
onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false); const presentation = useWorkspaceTabPresentation({
const anchorRef = useRef<View>(null); tab,
const tabIndexByKey = useMemo(() => { serverId: normalizedServerId,
const map = new Map<string, number>(); workspaceId: normalizedWorkspaceId,
tabs.forEach((tab, index) => {
map.set(tab.key, index);
}); });
return map;
}, [tabs]);
return (
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
style={({ hovered, pressed }) => [
styles.switcherTrigger,
(hovered || pressed || isOpen) && styles.switcherTriggerActive,
{ borderWidth: 0, borderColor: "transparent" },
Platform.OS === "web"
? {
outlineStyle: "solid",
outlineWidth: 0,
outlineColor: "transparent",
}
: null,
]}
onPress={() => setIsOpen(true)}
>
<View style={styles.switcherTriggerLeft}>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
{activeTabPresentation ? (
<WorkspaceTabIcon presentation={activeTabPresentation} active />
) : null}
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{activeTabLabel}
</Text>
</View>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.mobileTabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => onSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</View>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title="Switch tab"
searchPlaceholder="Search tabs"
open={isOpen}
onOpenChange={setIsOpen}
enableDismissOnClose={false}
anchorRef={anchorRef}
renderOption={({ option, selected, active, onPress }) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const presentation =
tabPresentationsByKey.get(option.id) ??
deriveWorkspaceTabPresentation({ tab });
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
const menuTestIDBase = `workspace-tab-menu-${tab.key}`; const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
const menuEntries = const menuEntries = buildWorkspaceTabMenuEntries({
tabIndex < 0
? []
: buildWorkspaceTabMenuEntries({
surface: "mobile", surface: "mobile",
tab, tab,
index: tabIndex, index: tabIndex,
tabCount: tabs.length, tabCount,
menuTestIDBase, menuTestIDBase,
onCopyResumeCommand, onCopyResumeCommand,
onCopyAgentId, onCopyAgentId,
@@ -285,6 +288,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onCloseTabsAfter: onCloseTabsBelow, onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs, onCloseOtherTabs,
}); });
return ( return (
<WorkspaceTabOptionRow <WorkspaceTabOptionRow
presentation={presentation} presentation={presentation}
@@ -334,12 +338,173 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
} }
/> />
); );
}
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
tabs,
activeTabKey,
activeTab,
tabSwitcherOptions,
tabByKey,
normalizedServerId,
normalizedWorkspaceId,
onSelectSwitcherTab,
onSelectNewTabOption,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
const map = new Map<string, number>();
tabs.forEach((tab, index) => {
map.set(tab.key, index);
});
return map;
}, [tabs]);
return (
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
style={({ hovered, pressed }) => [
styles.switcherTrigger,
(hovered || pressed || isOpen) && styles.switcherTriggerActive,
{ borderWidth: 0, borderColor: "transparent" },
Platform.OS === "web"
? {
outlineStyle: "solid",
outlineWidth: 0,
outlineColor: "transparent",
}
: null,
]}
onPress={() => setIsOpen(true)}
>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
</View>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.mobileTabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => onSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</View>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title="Switch tab"
searchPlaceholder="Search tabs"
open={isOpen}
onOpenChange={setIsOpen}
enableDismissOnClose={false}
anchorRef={anchorRef}
renderOption={({ option, selected, active, onPress }) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
}} }}
/> />
</View> </View>
); );
}); });
function PaneContent({
tab,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
onOpenWorkspaceFile,
}: {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onOpenWorkspaceFile: (filePath: string) => void;
}) {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const Component = registration.component;
return (
<PaneProvider
value={{
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
openFileInWorkspace: onOpenWorkspaceFile,
}}
>
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
</PaneProvider>
);
}
export function WorkspaceScreen({ export function WorkspaceScreen({
serverId, serverId,
workspaceId, workspaceId,
@@ -841,8 +1006,6 @@ function WorkspaceScreenContent({
const tabModel = useMemo( const tabModel = useMemo(
() => () =>
deriveWorkspaceTabModel({ deriveWorkspaceTabModel({
workspaceAgents,
terminals,
tabs: uiTabs, tabs: uiTabs,
tabOrder, tabOrder,
focusedTabId, focusedTabId,
@@ -857,7 +1020,7 @@ function WorkspaceScreenContent({
? { kind: "file", path: unresolvedOpenIntent.path } ? { kind: "file", path: unresolvedOpenIntent.path }
: null, : null,
}), }),
[focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents] [focusedTabId, tabOrder, uiTabs, unresolvedOpenIntent]
); );
const activeTabId = tabModel.activeTabId; const activeTabId = tabModel.activeTabId;
@@ -976,33 +1139,17 @@ function WorkspaceScreenContent({
}, [tabs]); }, [tabs]);
const activeTabKey = activeTabId ?? ""; const activeTabKey = activeTabId ?? "";
const tabPresentationsByKey = useMemo(() => {
const map = new Map<string, ReturnType<typeof deriveWorkspaceTabPresentation>>();
for (const tab of tabs) {
const agent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
map.set(tab.key, deriveWorkspaceTabPresentation({ tab, agent }));
}
return map;
}, [agentsById, tabs]);
const activeTabPresentation = tabPresentationsByKey.get(activeTabKey) ?? null;
const tabSwitcherOptions = useMemo( const tabSwitcherOptions = useMemo(
() => () =>
tabs.map((tab) => ({ tabs.map((tab) => ({
id: tab.key, id: tab.key,
label: tabPresentationsByKey.get(tab.key)?.titleState === "loading" ? "Loading..." : tab.label, label: getFallbackTabOptionLabel(tab),
description: tab.subtitle, description: getFallbackTabOptionDescription(tab),
})), })),
[tabPresentationsByKey, tabs] [tabs]
); );
const activeTabLabel = useMemo(() => {
if (activeTabPresentation?.titleState === "loading") {
return "Loading...";
}
return activeTabPresentation?.label ?? "Select tab";
}, [activeTabPresentation]);
const handleCreateDraftTab = useCallback(() => { const handleCreateDraftTab = useCallback(() => {
openWorkspaceDraftTab(); openWorkspaceDraftTab();
}, [openWorkspaceDraftTab]); }, [openWorkspaceDraftTab]);
@@ -1176,12 +1323,12 @@ function WorkspaceScreenContent({
if (!tab) { if (!tab) {
return; return;
} }
if (tab.kind === "terminal") { if (tab.target.kind === "terminal") {
await handleCloseTerminalTab({ tabId, terminalId: tab.terminalId }); await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
return; return;
} }
if (tab.kind === "agent") { if (tab.target.kind === "agent") {
await handleCloseAgentTab({ tabId, agentId: tab.agentId }); await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
return; return;
} }
handleCloseDraftOrFileTab(tabId); handleCloseDraftOrFileTab(tabId);
@@ -1434,114 +1581,59 @@ function WorkspaceScreenContent({
handle: handleWorkspaceTabAction, handle: handleWorkspaceTabAction,
}); });
const renderContent = () => { const activeTabDescriptor = activeTab?.descriptor ?? null;
if ( const content = shouldRenderMissingWorkspaceDescriptor({
shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
}) }) ? (
) {
return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<ActivityIndicator color={theme.colors.foregroundMuted} /> <ActivityIndicator color={theme.colors.foregroundMuted} />
</View> </View>
); ) : !activeTabDescriptor ? (
} !hasHydratedAgents ? (
const target = activeTab?.target ?? null;
if (!target) {
if (!hasHydratedAgents) {
return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<ActivityIndicator color={theme.colors.foregroundMuted} /> <ActivityIndicator color={theme.colors.foregroundMuted} />
</View> </View>
); ) : (
}
return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyStateText}> <Text style={styles.emptyStateText}>
No tabs are available yet. Use New tab to create an agent or terminal. No tabs are available yet. Use New tab to create an agent or terminal.
</Text> </Text>
</View> </View>
); )
} ) : (
<PaneContent
if (target.kind === "draft") { tab={activeTabDescriptor}
const tabId = activeTabId ?? target.draftId; normalizedServerId={normalizedServerId}
return ( normalizedWorkspaceId={normalizedWorkspaceId}
<WorkspaceDraftAgentTab onOpenTab={(target) => {
key={`${normalizedServerId}:${normalizedWorkspaceId}:${tabId}`}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={handleOpenFileFromChat}
onCreated={(agentSnapshot) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
retargetWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
target: { kind: "agent", agentId: agentSnapshot.id },
});
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, normalized);
return next;
});
}}
/>
);
}
if (target.kind === "agent") {
return (
<AgentReadyScreen
key={`${normalizedServerId}:${normalizedWorkspaceId}:${target.agentId}`}
serverId={normalizedServerId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={handleOpenFileFromChat}
/>
);
}
if (target.kind === "file") {
return (
<FilePane
serverId={normalizedServerId}
workspaceRoot={normalizedWorkspaceId}
filePath={target.path}
/>
);
}
return isScreenFocused ? (
<TerminalPane
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (!terminalId) {
return;
}
const tabId = openOrFocusTab({ const tabId = openOrFocusTab({
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
target: { kind: "terminal", terminalId }, target,
}); });
if (tabId) { if (tabId) {
navigateToTabId(tabId); navigateToTabId(tabId);
} }
}} }}
hideHeader onCloseCurrentTab={() => {
manageTerminalDirectorySubscription={false} if (activeTabDescriptor.tabId) {
void handleCloseTabById(activeTabDescriptor.tabId);
}
}}
onRetargetCurrentTab={(target) => {
retargetWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: activeTabDescriptor.tabId,
target,
});
}}
onOpenWorkspaceFile={(filePath) => {
handleOpenFileFromChat({ filePath });
}}
/> />
) : (
<View style={styles.contentPlaceholder} />
); );
};
return ( return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
@@ -1728,11 +1820,11 @@ function WorkspaceScreenContent({
<MobileWorkspaceTabSwitcher <MobileWorkspaceTabSwitcher
tabs={tabs} tabs={tabs}
activeTabKey={activeTabKey} activeTabKey={activeTabKey}
activeTabLabel={activeTabLabel} activeTab={activeTabDescriptor}
activeTabPresentation={activeTabPresentation}
tabSwitcherOptions={tabSwitcherOptions} tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey} tabByKey={tabByKey}
tabPresentationsByKey={tabPresentationsByKey} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab} onSelectSwitcherTab={handleSelectSwitcherTab}
onSelectNewTabOption={handleSelectNewTabOption} onSelectNewTabOption={handleSelectNewTabOption}
onCopyResumeCommand={handleCopyResumeCommand} onCopyResumeCommand={handleCopyResumeCommand}
@@ -1746,8 +1838,8 @@ function WorkspaceScreenContent({
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
tabs={tabs} tabs={tabs}
activeTabKey={activeTabKey} activeTabKey={activeTabKey}
agentsById={agentsById}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -1770,10 +1862,10 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}> <View style={styles.centerContent}>
{isMobile ? ( {isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y"> <GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{renderContent()}</View> <View style={styles.content}>{content}</View>
</GestureDetector> </GestureDetector>
) : ( ) : (
<View style={styles.content}>{renderContent()}</View> <View style={styles.content}>{content}</View>
)} )}
</View> </View>
</View> </View>
@@ -7,11 +7,7 @@ function createAgentTab(): WorkspaceTabDescriptor {
key: "agent_123", key: "agent_123",
tabId: "agent_123", tabId: "agent_123",
kind: "agent", kind: "agent",
agentId: "agent-123", target: { kind: "agent", agentId: "agent-123" },
provider: "codex",
label: "Agent 123",
subtitle: "/repo",
titleState: "ready",
}; };
} }
@@ -84,9 +80,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
key: "draft_123", key: "draft_123",
tabId: "draft_123", tabId: "draft_123",
kind: "draft", kind: "draft",
draftId: "draft_123", target: { kind: "draft", draftId: "draft_123" },
label: "New agent",
subtitle: "/repo",
}, },
index: 0, index: 0,
tabCount: 1, tabCount: 1,
@@ -68,14 +68,15 @@ export function buildWorkspaceTabMenuEntries(
const isOnlyTab = tabCount <= 1; const isOnlyTab = tabCount <= 1;
const entries: WorkspaceTabMenuEntry[] = []; const entries: WorkspaceTabMenuEntry[] = [];
if (tab.kind === "agent") { if (tab.target.kind === "agent") {
const { agentId } = tab.target;
entries.push({ entries.push({
kind: "item", kind: "item",
key: "copy-resume-command", key: "copy-resume-command",
label: "Copy resume command", label: "Copy resume command",
testID: `${menuTestIDBase}-copy-resume-command`, testID: `${menuTestIDBase}-copy-resume-command`,
onSelect: () => { onSelect: () => {
void onCopyResumeCommand(tab.agentId); void onCopyResumeCommand(agentId);
}, },
}); });
entries.push({ entries.push({
@@ -84,7 +85,7 @@ export function buildWorkspaceTabMenuEntries(
label: "Copy agent id", label: "Copy agent id",
testID: `${menuTestIDBase}-copy-agent-id`, testID: `${menuTestIDBase}-copy-agent-id`,
onSelect: () => { onSelect: () => {
void onCopyAgentId(tab.agentId); void onCopyAgentId(agentId);
}, },
}); });
entries.push({ entries.push({
@@ -1,85 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
function makeAgent(input: {
id: string;
provider?: Agent["provider"];
title?: string | null;
createdAt?: Date;
lastActivityAt?: Date;
requiresAttention?: boolean;
attentionReason?: Agent["attentionReason"];
}): Agent {
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
const lastActivityAt = input.lastActivityAt ?? createdAt;
return {
serverId: "srv",
id: input.id,
provider: input.provider ?? "codex",
status: "idle",
createdAt,
updatedAt: createdAt,
lastUserMessageAt: null,
lastActivityAt,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: input.provider ?? "codex",
sessionId: null,
},
title: input.title ?? null,
cwd: "/repo/worktree",
model: null,
thinkingOptionId: null,
labels: {},
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: null,
archivedAt: null,
};
}
describe("deriveWorkspaceTabModel", () => { describe("deriveWorkspaceTabModel", () => {
it("derives agent and terminal tabs from domain state, not UI membership", () => { it("keeps normalized tabs in stored order and preserves targets", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [
makeAgent({ id: "agent-a", title: "Build API" }),
makeAgent({ id: "agent-b", title: "" }),
],
terminals: [{ id: "term-1", name: "shell" }],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
],
tabOrder: [],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
"agent_agent-a",
"agent_agent-b",
"terminal_term-1",
]);
const firstAgent = model.tabs[0]?.descriptor;
const secondAgent = model.tabs[1]?.descriptor;
expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready");
expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading");
expect(secondAgent?.label).toBe("");
});
it("keeps draft and file tabs as explicit UI tabs", () => {
const uiTabs: WorkspaceTab[] = [ const uiTabs: WorkspaceTab[] = [
{ {
tabId: "draft_123", tabId: "draft_123",
@@ -91,26 +15,33 @@ describe("deriveWorkspaceTabModel", () => {
target: { kind: "file", path: "/repo/worktree/README.md" }, target: { kind: "file", path: "/repo/worktree/README.md" },
createdAt: 2, createdAt: 2,
}, },
{
tabId: "agent_agent-a",
target: { kind: "agent", agentId: "agent-a" },
createdAt: 3,
},
]; ];
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })], tabs: uiTabs,
terminals: [],
tabs: [
...uiTabs,
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 },
],
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"], tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
}); });
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]); expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
expect(model.tabs[0]?.descriptor.label).toBe("New Agent"); "draft_123",
"agent_agent-a",
"file_/repo/worktree/README.md",
]);
expect(model.tabs[0]?.descriptor.target).toEqual({ kind: "draft", draftId: "draft_123" });
expect(model.tabs[1]?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
expect(model.tabs[2]?.descriptor.target).toEqual({
kind: "file",
path: "/repo/worktree/README.md",
});
}); });
it("applies stored order and appends newly-derived tabs deterministically", () => { it("applies stored order and appends unordered tabs deterministically", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "zsh" }],
tabs: [ tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
@@ -128,8 +59,6 @@ describe("deriveWorkspaceTabModel", () => {
it("uses focused tab when present, otherwise falls back to first tab", () => { it("uses focused tab when present, otherwise falls back to first tab", () => {
const base: Parameters<typeof deriveWorkspaceTabModel>[0] = { const base: Parameters<typeof deriveWorkspaceTabModel>[0] = {
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
tabs: [ tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
@@ -144,18 +73,11 @@ describe("deriveWorkspaceTabModel", () => {
}).activeTabId }).activeTabId
).toBe("agent_agent-b"); ).toBe("agent_agent-b");
expect( expect(deriveWorkspaceTabModel(base).activeTabId).toBe("agent_agent-a");
deriveWorkspaceTabModel({
...base,
focusedTabId: "agent_agent-b",
}).activeTabId
).toBe("agent_agent-b");
}); });
it("prefers the route-selected target over stale focused tab state", () => { it("prefers the route-selected target over stale focused tab state", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
tabs: [ tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
@@ -166,59 +88,11 @@ describe("deriveWorkspaceTabModel", () => {
}); });
expect(model.activeTabId).toBe("agent_agent-b"); expect(model.activeTabId).toBe("agent_agent-b");
expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" }); expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
}); });
it("re-resolves active content for a new workspace when prior focused tab is not available", () => { it("keeps retargeted tab ids stable while matching upgraded targets", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
terminals: [],
tabs: [
{
tabId: "agent_workspace-b-agent",
target: { kind: "agent", agentId: "workspace-b-agent" },
createdAt: 1,
},
],
tabOrder: ["agent_workspace-b-agent"],
focusedTabId: "agent_workspace-a-agent",
});
expect(model.activeTabId).toBe("agent_workspace-b-agent");
expect(model.activeTab?.target).toEqual({
kind: "agent",
agentId: "workspace-b-agent",
});
});
it("does not materialize tabs absent from workspace tab membership", () => {
const offending = makeAgent({
id: "offender",
title: "Needs permission",
requiresAttention: true,
attentionReason: "permission",
});
const model = deriveWorkspaceTabModel({
workspaceAgents: [offending],
terminals: [],
tabs: [
{
tabId: "draft_123",
target: { kind: "draft", draftId: "draft_123" },
createdAt: 1,
},
],
tabOrder: ["draft_123"],
});
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false);
});
it("keeps retargeted agent tab id stable while upgrading descriptor data", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [],
terminals: [],
tabs: [ tabs: [
{ {
tabId: "draft_abc", tabId: "draft_abc",
@@ -227,52 +101,35 @@ describe("deriveWorkspaceTabModel", () => {
}, },
], ],
tabOrder: ["draft_abc"], tabOrder: ["draft_abc"],
});
const initial = model.tabs[0]?.descriptor;
expect(initial?.tabId).toBe("draft_abc");
expect(initial?.kind).toBe("agent");
if (initial?.kind === "agent") {
expect(initial.titleState).toBe("loading");
expect(initial.agentId).toBe("agent-1");
}
const upgraded = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
terminals: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
});
const upgradedDescriptor = upgraded.tabs[0]?.descriptor;
expect(upgradedDescriptor?.tabId).toBe("draft_abc");
if (upgradedDescriptor?.kind === "agent") {
expect(upgradedDescriptor.titleState).toBe("ready");
expect(upgradedDescriptor.label).toBe("Ready title");
}
});
it("prefers a retargeted tab when the route selects its upgraded agent target", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
terminals: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
focusedTabId: "some_other_tab",
preferredTarget: { kind: "agent", agentId: "agent-1" }, preferredTarget: { kind: "agent", agentId: "agent-1" },
}); });
expect(model.activeTabId).toBe("draft_abc"); expect(model.activeTabId).toBe("draft_abc");
expect(model.activeTab?.descriptor.tabId).toBe("draft_abc"); expect(model.activeTab?.descriptor.tabId).toBe("draft_abc");
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-1" });
});
it("normalizes file paths and discards invalid tabs", () => {
const model = deriveWorkspaceTabModel({
tabs: [
{
tabId: "file_path",
target: { kind: "file", path: "\\repo\\worktree\\README.md" },
createdAt: 1,
},
{
tabId: "",
target: { kind: "agent", agentId: "agent-a" },
createdAt: 2,
},
],
tabOrder: [],
});
expect(model.tabs).toHaveLength(1);
expect(model.tabs[0]?.descriptor.target).toEqual({
kind: "file",
path: "/repo/worktree/README.md",
});
}); });
}); });
@@ -1,22 +1,15 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
type TerminalLike = { export interface WorkspaceDerivedTab {
id: string;
name?: string | null;
};
export type WorkspaceDerivedTab = {
descriptor: WorkspaceTabDescriptor; descriptor: WorkspaceTabDescriptor;
target: WorkspaceTabTarget; }
};
export type WorkspaceTabModel = { export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[]; tabs: WorkspaceDerivedTab[];
activeTabId: string | null; activeTabId: string | null;
activeTab: WorkspaceDerivedTab | null; activeTab: WorkspaceDerivedTab | null;
}; }
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -45,33 +38,6 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b
return false; return false;
} }
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 normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null { function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") { if (!tab || typeof tab !== "object") {
return null; return null;
@@ -144,16 +110,12 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
} }
export function deriveWorkspaceTabModel(input: { export function deriveWorkspaceTabModel(input: {
workspaceAgents: Agent[];
terminals: TerminalLike[];
tabs: WorkspaceTab[]; tabs: WorkspaceTab[];
tabOrder: string[]; tabOrder: string[];
focusedTabId?: string | null; focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null; preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel { }): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>(); const tabsById = new Map<string, WorkspaceDerivedTab>();
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal]));
const normalizedTabs = input.tabs const normalizedTabs = input.tabs
.map((tab) => normalizeWorkspaceTab(tab)) .map((tab) => normalizeWorkspaceTab(tab))
@@ -161,72 +123,14 @@ export function deriveWorkspaceTabModel(input: {
.sort((left, right) => left.createdAt - right.createdAt); .sort((left, right) => left.createdAt - right.createdAt);
for (const tab of normalizedTabs) { for (const tab of normalizedTabs) {
if (tab.target.kind === "draft") {
tabsById.set(tab.tabId, { tabsById.set(tab.tabId, {
target: tab.target,
descriptor: { descriptor: {
key: tab.tabId, key: tab.tabId,
tabId: tab.tabId, tabId: tab.tabId,
kind: "draft", kind: tab.target.kind,
draftId: tab.target.draftId,
label: "New Agent",
subtitle: "New Agent",
},
});
continue;
}
if (tab.target.kind === "agent") {
const agent = agentsById.get(tab.target.agentId) ?? null;
const label = resolveWorkspaceAgentTabLabel(agent?.title);
const provider = agent?.provider ?? "codex";
tabsById.set(tab.tabId, {
target: tab.target, target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "agent",
agentId: tab.target.agentId,
provider,
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
}, },
}); });
continue;
}
if (tab.target.kind === "terminal") {
const terminal = terminalsById.get(tab.target.terminalId) ?? null;
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "terminal",
terminalId: tab.target.terminalId,
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
subtitle: "Terminal",
},
});
continue;
}
if (tab.target.kind === "file") {
const filePath = tab.target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
tabsById.set(tab.tabId, {
target: tab.target,
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: "file",
filePath,
label: fileName,
subtitle: filePath,
},
});
}
} }
const orderedTabIds: string[] = []; const orderedTabIds: string[] = [];
@@ -260,7 +164,7 @@ export function deriveWorkspaceTabModel(input: {
return null; return null;
} }
const matchingTab = const matchingTab =
tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null; tabs.find((tab) => tabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? null;
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget); return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
})(); })();
@@ -1,50 +1,47 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native"; import { Check } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ClaudeIcon } from "@/components/icons/claude-icon"; import invariant from "tiny-invariant";
import { CodexIcon } from "@/components/icons/codex-icon";
import { SyncedLoader } from "@/components/synced-loader"; import { SyncedLoader } from "@/components/synced-loader";
import type { Agent } from "@/stores/session-store"; import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
deriveSidebarStateBucket,
type SidebarStateBucket,
} from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color"; import { getStatusDotColor } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
export type WorkspaceTabPresentation = { export interface WorkspaceTabPresentation {
key: string; key: string;
kind: WorkspaceTabDescriptor["kind"]; kind: WorkspaceTabDescriptor["kind"];
label: string; label: string;
subtitle: string; subtitle: string;
titleState: "ready" | "loading"; titleState: "ready" | "loading";
provider: Agent["provider"] | null; icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null; statusBucket: SidebarStateBucket | null;
}; }
export function deriveWorkspaceTabPresentation(input: { export function useWorkspaceTabPresentation(input: {
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
agent?: Agent | null; serverId: string;
workspaceId: string;
}): WorkspaceTabPresentation { }): WorkspaceTabPresentation {
const { tab, agent = null } = input; ensurePanelsRegistered();
const registration = getPanelRegistration(input.tab.kind);
invariant(registration, `No panel registration for kind: ${input.tab.kind}`);
const descriptor = registration.useDescriptor(input.tab.target, {
serverId: input.serverId,
workspaceId: input.workspaceId,
});
return { return {
key: tab.key, key: input.tab.key,
kind: tab.kind, kind: input.tab.kind,
label: tab.label, label: descriptor.label,
subtitle: tab.subtitle, subtitle: descriptor.subtitle,
titleState: tab.kind === "agent" ? tab.titleState : "ready", titleState: descriptor.titleState,
provider: tab.kind === "agent" ? tab.provider : null, icon: descriptor.icon,
statusBucket: statusBucket: descriptor.statusBucket,
tab.kind === "agent" && agent
? deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
})
: null,
}; };
} }
@@ -74,8 +71,8 @@ export function WorkspaceTabIcon({
const shouldShowLoader = shouldRenderSyncedStatusLoader({ const shouldShowLoader = shouldRenderSyncedStatusLoader({
bucket: presentation.statusBucket, bucket: presentation.statusBucket,
}); });
const Icon = presentation.icon;
if (presentation.kind === "agent") {
if (shouldShowLoader) { if (shouldShowLoader) {
return ( return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}> <View style={[styles.agentIconWrapper, { width: size, height: size }]}>
@@ -86,13 +83,7 @@ export function WorkspaceTabIcon({
return ( return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}> <View style={[styles.agentIconWrapper, { width: size, height: size }]}>
{presentation.provider === "claude" ? ( <Icon size={size} color={iconColor} />
<ClaudeIcon size={size} color={iconColor} />
) : presentation.provider === "codex" ? (
<CodexIcon size={size} color={iconColor} />
) : (
<Bot size={size} color={iconColor} />
)}
{statusDotColor ? ( {statusDotColor ? (
<View <View
style={[ style={[
@@ -106,17 +97,6 @@ export function WorkspaceTabIcon({
) : null} ) : null}
</View> </View>
); );
}
if (presentation.kind === "draft") {
return <Pencil size={size} color={iconColor} />;
}
if (presentation.kind === "file") {
return <FileText size={size} color={iconColor} />;
}
return <Terminal size={size} color={iconColor} />;
} }
type WorkspaceTabOptionRowProps = { type WorkspaceTabOptionRowProps = {
@@ -1,37 +1,8 @@
import type { Agent } from "@/stores/session-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export type WorkspaceTabDescriptor = export interface WorkspaceTabDescriptor {
| {
key: string; key: string;
tabId: string; tabId: string;
kind: "draft"; kind: WorkspaceTabTarget["kind"];
draftId: string; target: WorkspaceTabTarget;
label: string; }
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "agent";
agentId: string;
provider: Agent["provider"];
label: string;
subtitle: string;
titleState: "ready" | "loading";
}
| {
key: string;
tabId: string;
kind: "terminal";
terminalId: string;
label: string;
subtitle: string;
}
| {
key: string;
tabId: string;
kind: "file";
filePath: string;
label: string;
subtitle: string;
};