refactor: extract workspace layout actions and pane state logic

This commit is contained in:
Mohamed Boudra
2026-03-18 09:39:12 +07:00
parent 30a225ce9f
commit e2f20f0e24
14 changed files with 2156 additions and 1548 deletions
+92 -108
View File
@@ -16,12 +16,21 @@ import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -44,9 +53,10 @@ interface SplitContainerProps {
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
newTabAgentOptionId?: "__new_tab_agent__";
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void;
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void;
onSplitPane: (input: {
tabId: string;
@@ -71,9 +81,9 @@ interface SplitPaneDropData {
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
extends Omit<SplitContainerProps, "layout" | "workspaceKey"> {
node: SplitNode;
tabsById: Map<string, WorkspaceTab>;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
@@ -85,7 +95,6 @@ interface SplitPaneViewProps
extends Omit<
SplitNodeViewProps,
| "node"
| "tabsById"
| "focusedPaneId"
| "activeDragTabId"
| "showDropZones"
@@ -96,7 +105,7 @@ interface SplitPaneViewProps
| "onResizeSplit"
> {
pane: SplitPane;
tabsById: Map<string, WorkspaceTab>;
uiTabs: WorkspaceTab[];
focused: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
@@ -143,9 +152,7 @@ export function SplitContainer({
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId = "__new_tab_agent__",
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onMoveTabToPane,
@@ -167,14 +174,6 @@ export function SplitContainer({
})
);
const tabsById = useMemo(() => {
const next = new Map<string, WorkspaceTab>();
for (const tab of uiTabs) {
next.set(tab.tabId, tab);
}
return next;
}, [uiTabs]);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -215,8 +214,8 @@ export function SplitContainer({
return;
}
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById);
const targetTabs = getPaneTabDescriptors(targetPane, tabsById);
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
if (sourceIndex < 0 || targetIndex < 0) {
@@ -259,7 +258,7 @@ export function SplitContainer({
setDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById]
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
);
return (
@@ -272,7 +271,7 @@ export function SplitContainer({
>
<SplitNodeView
node={layout.root}
tabsById={tabsById}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -291,9 +290,7 @@ export function SplitContainer({
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
@@ -311,7 +308,7 @@ export function SplitContainer({
function SplitNodeView({
node,
tabsById,
uiTabs,
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
@@ -330,9 +327,7 @@ function SplitNodeView({
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onMoveTabToPane,
@@ -348,7 +343,7 @@ function SplitNodeView({
return (
<SplitPaneView
pane={node.pane}
tabsById={tabsById}
uiTabs={uiTabs}
focused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -367,9 +362,7 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -393,7 +386,7 @@ function SplitNodeView({
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
<SplitNodeView
node={child}
tabsById={tabsById}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -412,9 +405,7 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
@@ -444,7 +435,7 @@ function SplitNodeView({
function SplitPaneView({
pane,
tabsById,
uiTabs,
focused,
normalizedServerId,
normalizedWorkspaceId,
@@ -463,9 +454,7 @@ function SplitPaneView({
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
buildPaneContentModel,
onFocusPane,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -475,22 +464,64 @@ function SplitPaneView({
onDropPreviewChange,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]);
const paneModel = useMemo(
const paneState = useMemo(
() =>
deriveWorkspaceTabModel({
tabs: paneTabs.map((tab) => ({
tabId: tab.tabId,
target: tab.target,
createdAt: 0,
})),
focusedTabId: pane.focusedTabId,
deriveWorkspacePaneState({
pane,
tabs: uiTabs,
}),
[pane.focusedTabId, paneTabs]
[pane, uiTabs]
);
const paneTabs = useMemo(
() => paneState.tabs.map((tab) => tab.descriptor),
[paneState.tabs]
);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => {
const isClosingAgent =
tab.target.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.target.agentId,
});
const isClosingTerminal =
tab.target.kind === "terminal" &&
killTerminalPending &&
killTerminalId === tab.target.terminalId;
return {
tab,
isActive: tab.key === activeTabDescriptor?.key,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: isClosingAgent || isClosingTerminal,
};
}),
[
activeTabDescriptor?.key,
hoveredCloseTabKey,
isArchivingAgent,
killTerminalId,
killTerminalPending,
normalizedServerId,
paneTabs,
]
);
const paneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
paneId: pane.id,
tab: activeTabDescriptor,
})
: null,
[
activeTabDescriptor,
buildPaneContentModel,
pane.id,
]
);
const activeTabDescriptor = paneModel.activeTab
? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null
: null;
return (
<View
@@ -516,16 +547,11 @@ function SplitPaneView({
>
<WorkspaceDesktopTabsRow
paneId={pane.id}
tabs={paneTabs}
activeTabKey={activeTabDescriptor?.key ?? ""}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
@@ -544,30 +570,8 @@ function SplitPaneView({
</View>
<View style={styles.paneContent}>
{activeTabDescriptor ? (
<WorkspacePaneContent
tab={activeTabDescriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onOpenTab={(target) => {
onOpenPaneTab({
paneId: pane.id,
target,
});
}}
onCloseCurrentTab={() => {
void onCloseTab(activeTabDescriptor.tabId);
}}
onRetargetCurrentTab={(target) => {
onRetargetTab(activeTabDescriptor.tabId, target);
}}
onOpenWorkspaceFile={(filePath) => {
onOpenWorkspaceFile({
paneId: pane.id,
filePath,
});
}}
/>
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
renderPaneEmptyState?.() ?? null
)}
@@ -598,26 +602,6 @@ function collectPanesById(node: SplitNode): Map<string, SplitPane> {
return next;
}
function getPaneTabDescriptors(
pane: SplitPane,
tabsById: Map<string, WorkspaceTab>
): WorkspaceTabDescriptor[] {
const next: WorkspaceTabDescriptor[] = [];
for (const tabId of pane.tabIds) {
const tab = tabsById.get(tabId);
if (!tab) {
continue;
}
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
});
}
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
@@ -18,26 +18,30 @@ import {
WorkspaceTabIcon,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import {
buildWorkspaceDesktopTabActions,
type WorkspaceDesktopTabActions,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__";
export interface WorkspaceDesktopTabRowItem {
tab: WorkspaceTabDescriptor;
isActive: boolean;
isCloseHovered: boolean;
isClosingTab: boolean;
}
type WorkspaceDesktopTabsRowProps = {
paneId?: string;
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
tabs: WorkspaceDesktopTabRowItem[];
normalizedServerId: string;
normalizedWorkspaceId: string;
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
killTerminalPending: boolean;
killTerminalId: string | null;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
@@ -65,87 +69,41 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
return "Agent";
}
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,
presentation,
tooltipLabel,
resolvedTab,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
dragHandleProps,
}: {
tab: WorkspaceTabDescriptor;
index: number;
tabCount: number;
isActive: boolean;
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
isCloseHovered: boolean;
isClosingTab: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
presentation: WorkspaceTabPresentation;
tooltipLabel: string;
resolvedTab: WorkspaceDesktopTabActions;
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;
dragHandleProps: any;
}) {
const presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const { theme } = useUnistyles();
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,
});
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
return (
<ContextMenu key={tab.key}>
@@ -214,7 +172,7 @@ function TabChip({
{showCloseButton ? (
<Pressable
testID={getCloseButtonTestId(tab)}
testID={closeButtonTestId}
disabled={isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
@@ -280,15 +238,10 @@ function TabChip({
export function WorkspaceDesktopTabsRow({
paneId,
tabs,
activeTabKey,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
@@ -334,7 +287,7 @@ export function WorkspaceDesktopTabsRow({
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
const label = getFallbackTabLabel(tab);
const label = getFallbackTabLabel(tab.tab);
return label.length;
}),
[tabs]
@@ -365,10 +318,10 @@ export function WorkspaceDesktopTabsRow({
>
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={
@@ -376,52 +329,36 @@ export function WorkspaceDesktopTabsRow({
? (tab) => ({
kind: "workspace-tab",
paneId,
tabId: tab.tabId,
tabId: tab.tab.tabId,
})
: undefined
}
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;
renderItem={({ item, index, dragHandleProps }) => {
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}
<ResolvedDesktopTabChip
key={`${item.tab.key}:${item.tab.kind}`}
item={item}
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}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
/>
);
@@ -454,6 +391,99 @@ export function WorkspaceDesktopTabsRow({
);
}
function ResolvedDesktopTabChip({
item,
index,
tabCount,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
resolvedTabWidth,
showLabel,
showCloseButton,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
dragHandleProps,
}: {
item: WorkspaceDesktopTabRowItem;
index: number;
tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
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;
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: any;
}) {
const presentation = useWorkspaceTabPresentation({
tab: item.tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
const resolvedTab = useMemo(
() =>
buildWorkspaceDesktopTabActions({
tab: item.tab,
index,
tabCount,
onCopyResumeCommand,
onCopyAgentId,
onCloseTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
}),
[
index,
item.tab,
onCloseOtherTabs,
onCloseTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCopyAgentId,
onCopyResumeCommand,
tabCount,
]
);
return (
<TabChip
tab={item.tab}
isActive={item.isActive}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={showCloseButton}
isCloseHovered={item.isCloseHovered}
isClosingTab={item.isClosingTab}
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps}
/>
);
}
const styles = StyleSheet.create((theme) => ({
tabsContainer: {
borderBottomWidth: 1,
@@ -1,10 +1,17 @@
import type { ComponentType } from "react";
import invariant from "tiny-invariant";
import { PaneProvider } from "@/panels/pane-context";
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface WorkspacePaneContentProps {
export interface WorkspacePaneContentModel {
key: string;
Component: ComponentType;
paneContextValue: PaneContextValue;
}
export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
@@ -14,7 +21,7 @@ export interface WorkspacePaneContentProps {
onOpenWorkspaceFile: (filePath: string) => void;
}
export function WorkspacePaneContent({
export function buildWorkspacePaneContentModel({
tab,
normalizedServerId,
normalizedWorkspaceId,
@@ -22,26 +29,36 @@ export function WorkspacePaneContent({
onCloseCurrentTab,
onRetargetCurrentTab,
onOpenWorkspaceFile,
}: WorkspacePaneContentProps) {
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const Component = registration.component;
return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
openFileInWorkspace: onOpenWorkspaceFile,
},
};
}
export interface WorkspacePaneContentProps {
content: WorkspacePaneContentModel;
}
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
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 value={paneContextValue}>
<Component key={key} />
</PaneProvider>
);
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { deriveWorkspacePaneState, getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
return {
tabId,
target,
createdAt: 1,
};
}
describe("workspace-pane-state", () => {
it("selects the focused pane and keeps its tab order", () => {
const tabs: WorkspaceTab[] = [
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
createTab("terminal_term-1", { kind: "terminal", terminalId: "term-1" }),
];
const layout: WorkspaceLayout = {
root: {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{
kind: "pane",
pane: {
id: "left",
tabIds: ["file_/repo/README.md", "agent_agent-a"],
focusedTabId: "agent_agent-a",
},
},
{
kind: "pane",
pane: {
id: "right",
tabIds: ["terminal_term-1"],
focusedTabId: "terminal_term-1",
},
},
],
},
},
focusedPaneId: "left",
};
const state = deriveWorkspacePaneState({ layout, tabs });
expect(state.pane?.id).toBe("left");
expect(state.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
"file_/repo/README.md",
"agent_agent-a",
]);
expect(state.activeTabId).toBe("agent_agent-a");
});
it("falls back to the first ordered pane tab when focusedTabId is empty", () => {
const pane = {
id: "main",
tabIds: ["draft_1", "draft_2"],
focusedTabId: " ",
};
const tabs: WorkspaceTab[] = [
createTab("draft_2", { kind: "draft", draftId: "draft_2" }),
createTab("draft_1", { kind: "draft", draftId: "draft_1" }),
];
const state = deriveWorkspacePaneState({ pane, tabs });
expect(state.activeTabId).toBe("draft_1");
expect(getWorkspacePaneDescriptors({ pane, tabs }).map((tab) => tab.tabId)).toEqual([
"draft_1",
"draft_2",
]);
});
it("prefers a matching target over stale focused tab state", () => {
const pane = {
id: "main",
tabIds: ["agent_agent-a", "file_/repo/README.md"],
focusedTabId: "agent_agent-a",
};
const tabs: WorkspaceTab[] = [
createTab("agent_agent-a", { kind: "agent", agentId: "agent-a" }),
createTab("file_/repo/README.md", { kind: "file", path: "/repo/README.md" }),
];
const state = deriveWorkspacePaneState({
pane,
tabs,
preferredTarget: { kind: "file", path: "\\repo\\README.md" },
});
expect(state.activeTabId).toBe("file_/repo/README.md");
expect(state.activeTab?.descriptor.target).toEqual({
kind: "file",
path: "/repo/README.md",
});
});
});
@@ -0,0 +1,199 @@
import {
findPaneById,
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
export interface WorkspaceDerivedTab {
descriptor: WorkspaceTabDescriptor;
}
export interface WorkspacePaneState {
pane: SplitPane | null;
tabs: WorkspaceDerivedTab[];
focusedTabId: string | null;
activeTabId: string | null;
activeTab: WorkspaceDerivedTab | null;
}
interface NormalizeWorkspacePaneTabsResult {
tabs: WorkspaceDerivedTab[];
openTabIds: Set<string>;
}
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 normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") {
return null;
}
const tabId = trimNonEmpty(tab.tabId);
if (!tabId) {
return null;
}
const target = normalizeWorkspaceTabTarget(tab.target);
if (!target) {
return null;
}
return {
tabId,
target,
createdAt: tab.createdAt,
};
}
function orderPaneTabs(input: { pane: SplitPane | null; tabs: WorkspaceTab[] }): WorkspaceTab[] {
if (!input.pane) {
return input.tabs;
}
const tabsById = new Map<string, WorkspaceTab>();
for (const tab of input.tabs) {
tabsById.set(tab.tabId, tab);
}
const orderedTabs: WorkspaceTab[] = [];
for (const tabId of input.pane.tabIds) {
const tab = tabsById.get(tabId);
if (tab) {
orderedTabs.push(tab);
}
}
return orderedTabs;
}
function normalizeWorkspacePaneTabs(tabs: WorkspaceTab[]): NormalizeWorkspacePaneTabsResult {
const nextTabs: WorkspaceDerivedTab[] = [];
const openTabIds = new Set<string>();
for (const tab of tabs) {
const normalizedTab = normalizeWorkspaceTab(tab);
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
continue;
}
openTabIds.add(normalizedTab.tabId);
nextTabs.push({
descriptor: {
key: normalizedTab.tabId,
tabId: normalizedTab.tabId,
kind: normalizedTab.target.kind,
target: normalizedTab.target,
},
});
}
return {
tabs: nextTabs,
openTabIds,
};
}
function getActiveTabId(input: {
tabs: WorkspaceDerivedTab[];
openTabIds: Set<string>;
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): string | null {
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = normalizeWorkspaceTabTarget(input.preferredTarget ?? null);
const preferredTabId = (() => {
if (!preferredTarget) {
return null;
}
const matchingTab =
input.tabs.find((tab) => workspaceTabTargetsEqual(tab.descriptor.target, preferredTarget)) ??
null;
return matchingTab?.descriptor.tabId ?? buildDeterministicWorkspaceTabId(preferredTarget);
})();
if (preferredTabId && input.openTabIds.has(preferredTabId)) {
return preferredTabId;
}
if (focusedTabId && input.openTabIds.has(focusedTabId)) {
return focusedTabId;
}
return input.tabs[0]?.descriptor.tabId ?? null;
}
function getPane(input: {
layout: WorkspaceLayout | null;
pane?: SplitPane | null;
paneId?: string | null;
}): SplitPane | null {
if (input.pane) {
return input.pane;
}
const layout = input.layout;
if (!layout) {
return null;
}
const resolvedPaneId = trimNonEmpty(input.paneId) ?? layout.focusedPaneId;
if (!resolvedPaneId) {
return null;
}
return findPaneById(layout.root, resolvedPaneId);
}
export function deriveWorkspacePaneState(input: {
layout?: WorkspaceLayout | null;
pane?: SplitPane | null;
paneId?: string | null;
tabs: WorkspaceTab[];
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspacePaneState {
const pane = getPane({
layout: input.layout ?? null,
pane: input.pane ?? null,
paneId: input.paneId,
});
const orderedTabs = orderPaneTabs({
pane,
tabs: input.tabs,
});
const normalizedTabs = normalizeWorkspacePaneTabs(orderedTabs);
const focusedTabId = pane?.focusedTabId ?? trimNonEmpty(input.focusedTabId) ?? null;
const activeTabId = getActiveTabId({
tabs: normalizedTabs.tabs,
openTabIds: normalizedTabs.openTabIds,
focusedTabId,
preferredTarget: input.preferredTarget,
});
return {
pane,
tabs: normalizedTabs.tabs,
focusedTabId,
activeTabId,
activeTab:
activeTabId
? normalizedTabs.tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
: null,
};
}
export function getWorkspacePaneDescriptors(input: {
layout?: WorkspaceLayout | null;
pane?: SplitPane | null;
paneId?: string | null;
tabs: WorkspaceTab[];
}): WorkspaceTabDescriptor[] {
return deriveWorkspacePaneState(input).tabs.map((tab) => tab.descriptor);
}
@@ -54,10 +54,7 @@ import {
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -69,6 +66,10 @@ import {
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import {
@@ -95,10 +96,11 @@ import {
import {
deriveWorkspaceAgentVisibility,
} from "@/screens/workspace/workspace-agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
import {
deriveWorkspaceTabModel,
} from "@/screens/workspace/workspace-tab-model";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
buildWorkspacePaneContentModel,
WorkspacePaneContent,
} from "@/screens/workspace/workspace-pane-content";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
@@ -109,11 +111,6 @@ const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
interface FocusedPaneTabState {
tabs: WorkspaceTab[];
focusedTabId: string | null;
}
type WorkspaceScreenProps = {
serverId: string;
workspaceId: string;
@@ -177,82 +174,6 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
return tab.target.path;
}
function workspaceTabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId;
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
return false;
}
function getFocusedPaneTabState(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
}): FocusedPaneTabState {
const { layout, tabs } = input;
if (!layout) {
return {
tabs,
focusedTabId: null,
};
}
const focusedPane = findPaneById(layout.root, layout.focusedPaneId);
if (!focusedPane) {
return {
tabs,
focusedTabId: null,
};
}
const tabsById = new Map<string, WorkspaceTab>();
for (const tab of tabs) {
tabsById.set(tab.tabId, tab);
}
const orderedTabs: WorkspaceTab[] = [];
for (const tabId of focusedPane.tabIds) {
const tab = tabsById.get(tabId);
if (tab) {
orderedTabs.push(tab);
}
}
return {
tabs: orderedTabs,
focusedTabId: focusedPane.focusedTabId,
};
}
function getEffectiveFocusedTabId(input: FocusedPaneTabState): string | null {
return trimNonEmpty(input.focusedTabId) ?? input.tabs[0]?.tabId ?? null;
}
function getFocusedPane(layout: WorkspaceLayout | null): SplitPane | null {
if (!layout) {
return null;
}
return findPaneById(layout.root, layout.focusedPaneId);
}
function getFocusedPaneActiveTabId(pane: SplitPane | null): string | null {
if (!pane) {
return null;
}
return trimNonEmpty(pane.focusedTabId) ?? pane.tabIds[0] ?? null;
}
type MobileWorkspaceTabSwitcherProps = {
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
@@ -873,7 +794,7 @@ function WorkspaceScreenContent({
const focusedPaneTabState = useMemo(
() =>
getFocusedPaneTabState({
deriveWorkspacePaneState({
layout: workspaceLayout,
tabs: uiTabs,
}),
@@ -886,14 +807,19 @@ function WorkspaceScreenContent({
return null;
}
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedTarget) {
return null;
}
const existingTab =
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null;
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
if (existingTab) {
return existingTab.tabId;
}
const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState);
const tabId = openWorkspaceTab(persistenceKey, target);
const previousFocusedTabId = focusedPaneTabState.activeTabId;
const tabId = openWorkspaceTab(persistenceKey, normalizedTarget);
if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) {
focusWorkspaceTab(persistenceKey, previousFocusedTabId);
}
@@ -908,10 +834,11 @@ function WorkspaceScreenContent({
return null;
}
const target: WorkspaceTabTarget = {
const target = normalizeWorkspaceTabTarget({
kind: "draft",
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
};
});
invariant(target?.kind === "draft", "Draft tab target must be valid");
if (input?.focus === false) {
return ensureWorkspaceTab(target);
}
@@ -989,6 +916,24 @@ function WorkspaceScreenContent({
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
? openIntent
: null;
const resolvedPaneTabState = useMemo(
() =>
deriveWorkspacePaneState({
layout: workspaceLayout,
tabs: uiTabs,
preferredTarget:
unresolvedOpenIntent?.kind === "agent"
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
: unresolvedOpenIntent?.kind === "terminal"
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
: unresolvedOpenIntent?.kind === "draft"
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
: unresolvedOpenIntent?.kind === "file"
? { kind: "file", path: unresolvedOpenIntent.path }
: null,
}),
[uiTabs, unresolvedOpenIntent, workspaceLayout]
);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
@@ -1051,25 +996,7 @@ function WorkspaceScreenContent({
workspaceAgents,
]);
const tabModel = useMemo(
() =>
deriveWorkspaceTabModel({
tabs: focusedPaneTabState.tabs,
focusedTabId: focusedPaneTabState.focusedTabId,
preferredTarget:
unresolvedOpenIntent?.kind === "agent"
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
: unresolvedOpenIntent?.kind === "terminal"
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
: unresolvedOpenIntent?.kind === "draft"
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
: unresolvedOpenIntent?.kind === "file"
? { kind: "file", path: unresolvedOpenIntent.path }
: null,
}),
[focusedPaneTabState, unresolvedOpenIntent]
);
const activeTabId = tabModel.activeTabId;
const activeTabId = resolvedPaneTabState.activeTabId;
useEffect(() => {
if (!activeTabId || !persistenceKey) {
@@ -1078,11 +1005,11 @@ function WorkspaceScreenContent({
focusWorkspaceTab(persistenceKey, activeTabId);
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
const activeTab = tabModel.activeTab;
const activeTab = resolvedPaneTabState.activeTab;
const tabs = useMemo<WorkspaceTabDescriptor[]>(
() => tabModel.tabs.map((tab) => tab.descriptor),
[tabModel.tabs]
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
[resolvedPaneTabState.tabs]
);
const navigateToTabId = useCallback(
@@ -1625,13 +1552,13 @@ function WorkspaceScreenContent({
return true;
}
const focusedPane = getFocusedPane(workspaceLayout);
const focusedPane = focusedPaneTabState.pane;
if (!focusedPane) {
return true;
}
if (action.id === "workspace.pane.split.right") {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) {
splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId,
@@ -1643,7 +1570,7 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.split.down") {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) {
splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId,
@@ -1688,7 +1615,7 @@ function WorkspaceScreenContent({
direction === "up" ||
direction === "down"
) {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane);
const activePaneTabId = focusedPaneTabState.activeTabId;
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
if (activePaneTabId && adjacentPaneId) {
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
@@ -1712,6 +1639,8 @@ function WorkspaceScreenContent({
moveWorkspaceTabToPane,
persistenceKey,
splitWorkspacePane,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
workspaceLayout,
]
);
@@ -1752,6 +1681,67 @@ function WorkspaceScreenContent({
});
const activeTabDescriptor = activeTab?.descriptor ?? null;
const buildPaneContentModel = useCallback(
(input: {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
focusPaneBeforeOpen?: boolean;
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab: (target) => {
if (!persistenceKey) {
return;
}
if (input.focusPaneBeforeOpen && input.paneId) {
focusWorkspacePane(persistenceKey, input.paneId);
}
const tabId = openWorkspaceTab(persistenceKey, target);
if (tabId) {
navigateToTabId(tabId);
}
},
onCloseCurrentTab: () => {
void handleCloseTabById(input.tab.tabId);
},
onRetargetCurrentTab: (target) => {
if (!persistenceKey) {
return;
}
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (filePath) => {
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
handleOpenFileFromChat({ filePath });
},
}),
[
handleCloseTabById,
handleOpenFileFromChat,
focusWorkspacePane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
openWorkspaceTab,
persistenceKey,
retargetWorkspaceTab,
]
);
const activePaneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
tab: activeTabDescriptor,
paneId: focusedPaneTabState.pane?.id ?? null,
focusPaneBeforeOpen: false,
})
: null,
[activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id]
);
const content = shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
@@ -1772,34 +1762,7 @@ function WorkspaceScreenContent({
</View>
)
) : (
<WorkspacePaneContent
tab={activeTabDescriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onOpenTab={(target) => {
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTab(persistenceKey, target);
if (tabId) {
navigateToTabId(tabId);
}
}}
onCloseCurrentTab={() => {
if (activeTabDescriptor.tabId) {
void handleCloseTabById(activeTabDescriptor.tabId);
}
}}
onRetargetCurrentTab={(target) => {
if (!persistenceKey) {
return;
}
retargetWorkspaceTab(persistenceKey, activeTabDescriptor.tabId, target);
}}
onOpenWorkspaceFile={(filePath) => {
handleOpenFileFromChat({ filePath });
}}
/>
<WorkspacePaneContent content={activePaneContent!} />
);
return (
@@ -2034,23 +1997,13 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
onOpenPaneTab={({ paneId, target }) => {
focusWorkspacePane(persistenceKey, paneId);
const tabId = openWorkspaceTab(persistenceKey, target);
if (tabId) {
navigateToTabId(tabId);
}
}}
onRetargetTab={(tabId, target) => {
retargetWorkspaceTab(persistenceKey, tabId, target);
}}
onOpenWorkspaceFile={({ paneId, filePath }) => {
focusWorkspacePane(persistenceKey, paneId);
const tabId = openWorkspaceTab(persistenceKey, { kind: "file", path: filePath });
if (tabId) {
navigateToTabId(tabId);
}
}}
buildPaneContentModel={({ paneId, tab }) =>
buildPaneContentModel({
tab,
paneId,
focusPaneBeforeOpen: true,
})
}
onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId);
}}
@@ -1,4 +1,5 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}
interface BuildWorkspaceDesktopTabActionsInput {
tab: WorkspaceTabDescriptor;
index: number;
tabCount: number;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}
export interface WorkspaceDesktopTabActions {
contextMenuTestId: string;
menuEntries: WorkspaceTabMenuEntry[];
closeButtonTestId: string;
}
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
return surface === "mobile" ? "Close tabs above" : "Close to the left";
}
@@ -47,6 +66,19 @@ function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
return surface === "mobile" ? "close-below" : "close-right";
}
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)}`;
}
export function buildWorkspaceTabMenuEntries(
input: BuildWorkspaceTabMenuEntriesInput
): WorkspaceTabMenuEntry[] {
@@ -136,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
return entries;
}
export function buildWorkspaceDesktopTabActions(
input: BuildWorkspaceDesktopTabActionsInput
): WorkspaceDesktopTabActions {
const contextMenuTestId = `workspace-tab-context-${input.tab.key}`;
return {
contextMenuTestId,
menuEntries: buildWorkspaceTabMenuEntries({
surface: "desktop",
tab: input.tab,
index: input.index,
tabCount: input.tabCount,
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId,
onCloseTab: input.onCloseTab,
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,
onCloseOtherTabs: input.onCloseOtherTabs,
}),
closeButtonTestId: getCloseButtonTestId(input.tab),
};
}
@@ -87,6 +87,31 @@ describe("deriveWorkspaceTabModel", () => {
expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" });
});
it("normalizes preferredTarget before overriding focused tab selection", () => {
const model = deriveWorkspaceTabModel({
tabs: [
{
tabId: "file_/repo/worktree/README.md",
target: { kind: "file", path: "/repo/worktree/README.md" },
createdAt: 1,
},
{
tabId: "agent_agent-a",
target: { kind: "agent", agentId: "agent-a" },
createdAt: 2,
},
],
focusedTabId: "agent_agent-a",
preferredTarget: { kind: "file", path: "\\repo\\worktree\\README.md" },
});
expect(model.activeTabId).toBe("file_/repo/worktree/README.md");
expect(model.activeTab?.descriptor.target).toEqual({
kind: "file",
path: "/repo/worktree/README.md",
});
});
it("keeps retargeted tab ids stable while matching upgraded targets", () => {
const model = deriveWorkspaceTabModel({
tabs: [
@@ -1,9 +1,11 @@
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface WorkspaceDerivedTab {
descriptor: WorkspaceTabDescriptor;
}
import {
deriveWorkspacePaneState,
type WorkspaceDerivedTab,
} from "@/screens/workspace/workspace-pane-state";
import {
buildDeterministicWorkspaceTabId,
} from "@/utils/workspace-tab-identity";
export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[];
@@ -11,102 +13,8 @@ export interface WorkspaceTabModel {
activeTab: WorkspaceDerivedTab | null;
}
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 tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId;
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
return false;
}
function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") {
return null;
}
const tabId = trimNonEmpty(tab.tabId);
if (!tabId) {
return null;
}
if (!tab.target || typeof tab.target !== "object") {
return null;
}
if (tab.target.kind === "draft") {
const draftId = trimNonEmpty(tab.target.draftId);
if (!draftId) {
return null;
}
return {
tabId,
target: { kind: "draft", draftId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "agent") {
const agentId = trimNonEmpty(tab.target.agentId);
if (!agentId) {
return null;
}
return {
tabId,
target: { kind: "agent", agentId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "terminal") {
const terminalId = trimNonEmpty(tab.target.terminalId);
if (!terminalId) {
return null;
}
return {
tabId,
target: { kind: "terminal", terminalId },
createdAt: tab.createdAt,
};
}
if (tab.target.kind === "file") {
const path = trimNonEmpty(tab.target.path);
if (!path) {
return null;
}
return {
tabId,
target: { kind: "file", path: path.replace(/\\/g, "/") },
createdAt: tab.createdAt,
};
}
return null;
}
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
return `file_${target.path}`;
return buildDeterministicWorkspaceTabId(target);
}
export function deriveWorkspaceTabModel(input: {
@@ -114,51 +22,14 @@ export function deriveWorkspaceTabModel(input: {
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel {
const tabs: WorkspaceDerivedTab[] = [];
const openTabIds = new Set<string>();
for (const tab of input.tabs) {
const normalizedTab = normalizeWorkspaceTab(tab);
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
continue;
}
openTabIds.add(normalizedTab.tabId);
tabs.push({
descriptor: {
key: normalizedTab.tabId,
tabId: normalizedTab.tabId,
kind: normalizedTab.target.kind,
target: normalizedTab.target,
},
});
}
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = input.preferredTarget ?? null;
const preferredTabId = (() => {
if (!preferredTarget) {
return null;
}
const matchingTab =
tabs.find((tab) => tabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? null;
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
})();
const activeTabId =
preferredTabId && openTabIds.has(preferredTabId)
? preferredTabId
: focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;
const activeTab = activeTabId
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
: null;
const paneState = deriveWorkspacePaneState({
tabs: input.tabs,
focusedTabId: input.focusedTabId,
preferredTarget: input.preferredTarget,
});
return {
tabs,
activeTabId,
activeTab,
tabs: paneState.tabs,
activeTabId: paneState.activeTabId,
activeTab: paneState.activeTab,
};
}
File diff suppressed because it is too large Load Diff
@@ -121,7 +121,7 @@ describe("workspace-layout-store tree transforms", () => {
vi.restoreAllMocks();
});
it("insertSplit reuses a matching parent direction instead of nesting", () => {
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
@@ -141,10 +141,14 @@ describe("workspace-layout-store tree transforms", () => {
const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
expect(nextGroup.group.direction).toBe("horizontal");
expect(nextGroup.group.children).toHaveLength(3);
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
expect(nextGroup.group.children).toHaveLength(2);
expect(nextGroup.group.sizes).toEqual([0.25, 0.75]);
expect(nestedGroup.group.id).toBe("group_22222222-2222-2222-2222-222222222222");
expect(nestedGroup.group.direction).toBe("horizontal");
expect(nestedGroup.group.sizes).toEqual([0.5, 0.5]);
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
"left",
"right",
@@ -519,12 +523,33 @@ describe("workspace-layout-store actions", () => {
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989");
expect(paneCId).toBe("pane_9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
expect(layout.focusedPaneId).toBe(paneCId);
expect(rootGroup.group.direction).toBe("horizontal");
expect(rootGroup.group.children).toEqual([
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }),
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }),
expect(rootGroup.group.children).toHaveLength(2);
expect(
rootGroup.group.children.map((child) => {
expect(child.kind).toBe("pane");
if (child.kind !== "pane") {
throw new Error("Expected pane child");
}
return {
id: child.pane.id,
tabIds: child.pane.tabIds,
focusedTabId: child.pane.focusedTabId,
};
})
).toEqual([
{
id: "main",
tabIds: ["file_/repo/worktree/a.ts"],
focusedTabId: "file_/repo/worktree/a.ts",
},
{
id: paneCId!,
tabIds: ["file_/repo/worktree/c.ts"],
focusedTabId: "file_/repo/worktree/c.ts",
},
]);
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
});
@@ -560,7 +585,8 @@ describe("workspace-layout-store actions", () => {
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff");
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
@@ -583,17 +609,18 @@ describe("workspace-layout-store actions", () => {
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const splitGroup = expectGroup(splitRoot);
store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]);
const nestedGroup = expectGroup(splitGroup.group.children[1]!);
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const resizedGroup = expectGroup(resizedRoot);
const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]!);
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
expect(total).toBeCloseTo(1, 10);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined
): WorkspaceTabTarget | null {
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
return null;
}
if (value.kind === "draft") {
const draftId = trimNonEmpty(value.draftId);
return draftId ? { kind: "draft", draftId } : null;
}
if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null;
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
if (value.kind === "file") {
const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
}
return null;
}
export function workspaceTabTargetsEqual(
left: WorkspaceTabTarget,
right: WorkspaceTabTarget
): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId;
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
return false;
}
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
return `file_${target.path}`;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
+9 -2
View File
@@ -1,8 +1,15 @@
import { defineConfig, configDefaults } from "vitest/config";
import path from "path";
import fs from "fs";
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
const resolvePackageEntry = (packageName: string) => {
const appPackagePath = path.resolve(appNodeModules, packageName);
return fs.existsSync(appPackagePath)
? appPackagePath
: path.resolve(rootNodeModules, packageName);
};
export default defineConfig({
test: {
@@ -45,10 +52,10 @@ export default defineConfig({
find: "react-native",
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
},
{ find: "react", replacement: path.resolve(appNodeModules, "react") },
{ find: "react", replacement: resolvePackageEntry("react") },
{
find: "react-dom",
replacement: path.resolve(appNodeModules, "react-dom"),
replacement: resolvePackageEntry("react-dom"),
},
],
},