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,11 +49,242 @@ type WorkspaceDesktopTabsRowProps = {
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
}; };
function getFallbackTabLabel(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 "";
}
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,
normalizedWorkspaceId,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onPresentationChange,
dragHandleProps,
}: {
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 presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
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 menuEntries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab,
index,
tabCount,
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsBefore: onCloseTabsToLeft,
onCloseTabsAfter: onCloseTabsToRight,
onCloseOtherTabs,
});
return (
<ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
isActive && styles.tabActive,
!isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tooltipLabel}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
showCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
showCloseButton && styles.tabLabelWithCloseButton,
]}
selectable={false}
numberOfLines={1}
ellipsizeMode="tail"
>
{presentation.label}
</Text>
)
) : null}
</View>
{showCloseButton ? (
<Pressable
testID={getCloseButtonTestId(tab)}
disabled={isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{({ hovered, pressed }) =>
isClosingTab ? (
<ActivityIndicator
size={12}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<X
size={12}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<ContextMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
>
{entry.label}
</ContextMenuItem>
)
)}
</ContextMenuContent>
</ContextMenu>
);
}
export function WorkspaceDesktopTabsRow({ export function WorkspaceDesktopTabsRow({
tabs, tabs,
activeTabKey, activeTabKey,
agentsById,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey, hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
@@ -74,6 +305,43 @@ export function WorkspaceDesktopTabsRow({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0); const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = 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 handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width); const nextWidth = Math.round(event.nativeEvent.layout.width);
@@ -103,12 +371,14 @@ export function WorkspaceDesktopTabsRow({
const tabLabelLengths = useMemo( const tabLabelLengths = useMemo(
() => () =>
tabs.map((tab) => { tabs.map((tab) => {
if (tab.kind === "agent" && tab.titleState === "loading") { const presentation = presentationsByKey.get(tab.key);
if (presentation?.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth)); return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
} }
return tab.label.length; const label = presentation?.label ?? getFallbackTabLabel(tab);
return label.length;
}), }),
[layoutMetrics.estimatedCharWidth, tabs] [layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
); );
const { layout } = useWorkspaceTabLayout({ const { layout } = useWorkspaceTabLayout({
@@ -142,177 +412,49 @@ export function WorkspaceDesktopTabsRow({
onDragEnd={onReorderTabs} onDragEnd={onReorderTabs}
renderItem={({ item: tab, index, dragHandleProps }) => { renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey; const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key; const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent = const isClosingAgent =
tab.kind === "agent" && tab.target.kind === "agent" &&
isArchivingAgent({ isArchivingAgent({
serverId: normalizedServerId, serverId: normalizedServerId,
agentId: tab.agentId, agentId: tab.target.agentId,
}); });
const isClosingTerminal = const isClosingTerminal =
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId; tab.target.kind === "terminal" &&
killTerminalPending &&
killTerminalId === tab.target.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal; const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const shouldShowCloseButton = layout.closeButtonPolicy === "all";
const layoutItem = layout.items[index] ?? null; const layoutItem = layout.items[index] ?? null;
const resolvedTabWidth = layoutItem?.width ?? 150; const resolvedTabWidth = layoutItem?.width ?? 150;
const showLabel = layoutItem?.showLabel ?? true; const showLabel = layoutItem?.showLabel ?? true;
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
const tooltipLabel =
tab.kind === "agent" && tab.titleState === "loading"
? "Loading agent title"
: presentation.label;
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab,
index,
tabCount: tabs.length,
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsBefore: onCloseTabsToLeft,
onCloseTabsAfter: onCloseTabsToRight,
onCloseOtherTabs,
});
return ( return (
<ContextMenu key={tab.key}> <TabChip
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}> key={tab.key}
<TooltipTrigger asChild triggerRefProp="triggerRef"> tab={tab}
<ContextMenuTrigger index={index}
testID={`workspace-tab-${tab.key}`} tabCount={tabs.length}
enabledOnMobile={false} isActive={isActive}
style={({ hovered, pressed }) => [ resolvedTabWidth={resolvedTabWidth}
styles.tab, showLabel={showLabel}
{ showCloseButton={shouldShowCloseButton}
minWidth: resolvedTabWidth, isCloseHovered={isCloseHovered}
width: resolvedTabWidth, isClosingTab={isClosingTab}
maxWidth: resolvedTabWidth, normalizedServerId={normalizedServerId}
}, normalizedWorkspaceId={normalizedWorkspaceId}
isActive && styles.tabActive, setHoveredTabKey={setHoveredTabKey}
!isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered, setHoveredCloseTabKey={setHoveredCloseTabKey}
]} onNavigateTab={onNavigateTab}
onHoverIn={() => { onCloseTab={onCloseTab}
setHoveredTabKey(tab.key); onCopyResumeCommand={onCopyResumeCommand}
}} onCopyAgentId={onCopyAgentId}
onHoverOut={() => { onCloseTabsToLeft={onCloseTabsToLeft}
setHoveredTabKey((current) => (current === tab.key ? null : current)); onCloseTabsToRight={onCloseTabsToRight}
}} onCloseOtherTabs={onCloseOtherTabs}
onPressIn={() => { onPresentationChange={handlePresentationChange}
onNavigateTab(tab.tabId); dragHandleProps={dragHandleProps}
}} />
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tooltipLabel}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
selectable={false}
numberOfLines={1}
ellipsizeMode="tail"
>
{tab.label}
</Text>
)
) : null}
</View>
{shouldShowCloseButton ? (
<Pressable
testID={
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}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{({ hovered, pressed }) =>
isClosingTab ? (
<ActivityIndicator
size={12}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<X
size={12}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<ContextMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
>
{entry.label}
</ContextMenuItem>
)
)}
</ContextMenuContent>
</ContextMenu>
); );
}} }}
/> />
@@ -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,14 +187,167 @@ type MobileWorkspaceTabSwitcherProps = {
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}; };
function MobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
}: {
activeTab: WorkspaceTabDescriptor | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
}) {
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,
onCopyAgentId,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: {
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 presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
tabCount,
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
});
return (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
trailingAccessory={
<DropdownMenu>
<DropdownMenuTrigger
testID={`${menuTestIDBase}-trigger`}
accessibilityRole="button"
accessibilityLabel={`Open menu for ${presentation.label}`}
hitSlop={8}
style={({ open, pressed }) => [
styles.mobileTabMenuTrigger,
(open || pressed) && styles.mobileTabMenuTriggerActive,
]}
>
<Ellipsis
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
width={220}
testID={menuTestIDBase}
>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<DropdownMenuSeparator key={entry.key} />
) : (
<DropdownMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
>
{entry.label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
}
/>
);
}
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
tabs, tabs,
activeTabKey, activeTabKey,
activeTabLabel, activeTab,
activeTabPresentation,
tabSwitcherOptions, tabSwitcherOptions,
tabByKey, tabByKey,
tabPresentationsByKey, normalizedServerId,
normalizedWorkspaceId,
onSelectSwitcherTab, onSelectSwitcherTab,
onSelectNewTabOption, onSelectNewTabOption,
onCopyResumeCommand, onCopyResumeCommand,
@@ -211,15 +388,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
onPress={() => setIsOpen(true)} onPress={() => setIsOpen(true)}
> >
<View style={styles.switcherTriggerLeft}> <View style={styles.switcherTriggerLeft}>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon"> <MobileActiveTabTrigger
{activeTabPresentation ? ( activeTab={activeTab}
<WorkspaceTabIcon presentation={activeTabPresentation} active /> normalizedServerId={normalizedServerId}
) : null} normalizedWorkspaceId={normalizedWorkspaceId}
</View> />
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{activeTabLabel}
</Text>
</View> </View>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -264,74 +437,26 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
if (!tab) { if (!tab) {
return <View />; return <View />;
} }
const presentation =
tabPresentationsByKey.get(option.id) ??
deriveWorkspaceTabPresentation({ tab });
const tabIndex = tabIndexByKey.get(tab.key) ?? -1; const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
const menuTestIDBase = `workspace-tab-menu-${tab.key}`; if (tabIndex < 0) {
const menuEntries = return <View />;
tabIndex < 0 }
? []
: buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
tabCount: tabs.length,
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
});
return ( return (
<WorkspaceTabOptionRow <MobileWorkspaceTabOption
presentation={presentation} tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected} selected={selected}
active={active} active={active}
onPress={onPress} onPress={onPress}
trailingAccessory={ onCopyResumeCommand={onCopyResumeCommand}
<DropdownMenu> onCopyAgentId={onCopyAgentId}
<DropdownMenuTrigger onCloseTab={onCloseTab}
testID={`${menuTestIDBase}-trigger`} onCloseTabsAbove={onCloseTabsAbove}
accessibilityRole="button" onCloseTabsBelow={onCloseTabsBelow}
accessibilityLabel={`Open menu for ${presentation.label}`} onCloseOtherTabs={onCloseOtherTabs}
hitSlop={8}
style={({ open, pressed }) => [
styles.mobileTabMenuTrigger,
(open || pressed) && styles.mobileTabMenuTriggerActive,
]}
>
<Ellipsis
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
width={220}
testID={menuTestIDBase}
>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<DropdownMenuSeparator key={entry.key} />
) : (
<DropdownMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
>
{entry.label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
}
/> />
); );
}} }}
@@ -340,6 +465,46 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
); );
}); });
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, }) ? (
}) <View style={styles.emptyState}>
) { <ActivityIndicator color={theme.colors.foregroundMuted} />
return ( </View>
<View style={styles.emptyState}> ) : !activeTabDescriptor ? (
<ActivityIndicator color={theme.colors.foregroundMuted} /> !hasHydratedAgents ? (
</View> <View style={styles.emptyState}>
); <ActivityIndicator color={theme.colors.foregroundMuted} />
} </View>
const target = activeTab?.target ?? null;
if (!target) {
if (!hasHydratedAgents) {
return (
<View style={styles.emptyState}>
<ActivityIndicator color={theme.colors.foregroundMuted} />
</View>
);
}
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
No tabs are available yet. Use New tab to create an agent or terminal.
</Text>
</View>
);
}
if (target.kind === "draft") {
const tabId = activeTabId ?? target.draftId;
return (
<WorkspaceDraftAgentTab
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({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "terminal", terminalId },
});
if (tabId) {
navigateToTabId(tabId);
}
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
) : ( ) : (
<View style={styles.contentPlaceholder} /> <View style={styles.emptyState}>
); <Text style={styles.emptyStateText}>
}; No tabs are available yet. Use New tab to create an agent or terminal.
</Text>
</View>
)
) : (
<PaneContent
tab={activeTabDescriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onOpenTab={(target) => {
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target,
});
if (tabId) {
navigateToTabId(tabId);
}
}}
onCloseCurrentTab={() => {
if (activeTabDescriptor.tabId) {
void handleCloseTabById(activeTabDescriptor.tabId);
}
}}
onRetargetCurrentTab={(target) => {
retargetWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: activeTabDescriptor.tabId,
target,
});
}}
onOpenWorkspaceFile={(filePath) => {
handleOpenFileFromChat({ filePath });
}}
/>
);
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, { descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target, target: tab.target,
descriptor: { },
key: tab.tabId, });
tabId: tab.tabId,
kind: "draft",
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,
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);
})(); })();
@@ -268,8 +172,8 @@ export function deriveWorkspaceTabModel(input: {
preferredTabId && openTabIds.has(preferredTabId) preferredTabId && openTabIds.has(preferredTabId)
? preferredTabId ? preferredTabId
: focusedTabId && openTabIds.has(focusedTabId) : focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId ? focusedTabId
: tabs[0]?.descriptor.tabId ?? null; : tabs[0]?.descriptor.tabId ?? null;
const activeTab = activeTabId const activeTab = activeTabId
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null ? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
@@ -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,49 +71,32 @@ 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 (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
<SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
</View>
);
}
return ( return (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}> <View style={[styles.agentIconWrapper, { width: size, height: size }]}>
{presentation.provider === "claude" ? ( <SyncedLoader size={size - 1} color={theme.colors.palette.amber[500]} />
<ClaudeIcon size={size} color={iconColor} />
) : presentation.provider === "codex" ? (
<CodexIcon size={size} color={iconColor} />
) : (
<Bot size={size} color={iconColor} />
)}
{statusDotColor ? (
<View
style={[
styles.statusDot,
{
backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
},
]}
/>
) : null}
</View> </View>
); );
} }
if (presentation.kind === "draft") { return (
return <Pencil size={size} color={iconColor} />; <View style={[styles.agentIconWrapper, { width: size, height: size }]}>
} <Icon size={size} color={iconColor} />
{statusDotColor ? (
if (presentation.kind === "file") { <View
return <FileText size={size} color={iconColor} />; style={[
} styles.statusDot,
{
return <Terminal size={size} color={iconColor} />; backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
},
]}
/>
) : null}
</View>
);
} }
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: WorkspaceTabTarget["kind"];
kind: "draft"; target: WorkspaceTabTarget;
draftId: string; }
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;
};