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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle"; import { ResizeHandle } from "@/components/resize-handle";
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone"; import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; import {
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; deriveWorkspacePaneState,
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; 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 { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store"; 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 { interface SplitContainerProps {
layout: WorkspaceLayout; layout: WorkspaceLayout;
@@ -44,9 +53,10 @@ interface SplitContainerProps {
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void; onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
newTabAgentOptionId?: "__new_tab_agent__"; newTabAgentOptionId?: "__new_tab_agent__";
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void; buildPaneContentModel: (input: {
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void; paneId: string;
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void; tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void; onFocusPane: (paneId: string) => void;
onSplitPane: (input: { onSplitPane: (input: {
tabId: string; tabId: string;
@@ -71,9 +81,9 @@ interface SplitPaneDropData {
} }
interface SplitNodeViewProps interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> { extends Omit<SplitContainerProps, "layout" | "workspaceKey"> {
node: SplitNode; node: SplitNode;
tabsById: Map<string, WorkspaceTab>; uiTabs: WorkspaceTab[];
focusedPaneId: string; focusedPaneId: string;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
@@ -85,7 +95,6 @@ interface SplitPaneViewProps
extends Omit< extends Omit<
SplitNodeViewProps, SplitNodeViewProps,
| "node" | "node"
| "tabsById"
| "focusedPaneId" | "focusedPaneId"
| "activeDragTabId" | "activeDragTabId"
| "showDropZones" | "showDropZones"
@@ -96,7 +105,7 @@ interface SplitPaneViewProps
| "onResizeSplit" | "onResizeSplit"
> { > {
pane: SplitPane; pane: SplitPane;
tabsById: Map<string, WorkspaceTab>; uiTabs: WorkspaceTab[];
focused: boolean; focused: boolean;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
@@ -143,9 +152,7 @@ export function SplitContainer({
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
newTabAgentOptionId = "__new_tab_agent__", newTabAgentOptionId = "__new_tab_agent__",
onOpenPaneTab, buildPaneContentModel,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onMoveTabToPane, 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 panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -215,8 +214,8 @@ export function SplitContainer({
return; return;
} }
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById); const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getPaneTabDescriptors(targetPane, tabsById); const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId); const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
if (sourceIndex < 0 || targetIndex < 0) { if (sourceIndex < 0 || targetIndex < 0) {
@@ -259,7 +258,7 @@ export function SplitContainer({
setDropPreview(null); setDropPreview(null);
}, },
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById] [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, uiTabs]
); );
return ( return (
@@ -272,7 +271,7 @@ export function SplitContainer({
> >
<SplitNodeView <SplitNodeView
node={layout.root} node={layout.root}
tabsById={tabsById} uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId} focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
@@ -291,9 +290,7 @@ export function SplitContainer({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab} buildPaneContentModel={buildPaneContentModel}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane} onMoveTabToPane={onMoveTabToPane}
@@ -311,7 +308,7 @@ export function SplitContainer({
function SplitNodeView({ function SplitNodeView({
node, node,
tabsById, uiTabs,
focusedPaneId, focusedPaneId,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
@@ -330,9 +327,7 @@ function SplitNodeView({
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
newTabAgentOptionId, newTabAgentOptionId,
onOpenPaneTab, buildPaneContentModel,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
onMoveTabToPane, onMoveTabToPane,
@@ -348,7 +343,7 @@ function SplitNodeView({
return ( return (
<SplitPaneView <SplitPaneView
pane={node.pane} pane={node.pane}
tabsById={tabsById} uiTabs={uiTabs}
focused={node.pane.id === focusedPaneId} focused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
@@ -367,9 +362,7 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab} buildPaneContentModel={buildPaneContentModel}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onReorderTabsInPane={onReorderTabsInPane} onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState} renderPaneEmptyState={renderPaneEmptyState}
@@ -393,7 +386,7 @@ function SplitNodeView({
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}> <View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
<SplitNodeView <SplitNodeView
node={child} node={child}
tabsById={tabsById} uiTabs={uiTabs}
focusedPaneId={focusedPaneId} focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
@@ -412,9 +405,7 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption} onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId} newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab} buildPaneContentModel={buildPaneContentModel}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane} onMoveTabToPane={onMoveTabToPane}
@@ -444,7 +435,7 @@ function SplitNodeView({
function SplitPaneView({ function SplitPaneView({
pane, pane,
tabsById, uiTabs,
focused, focused,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
@@ -463,9 +454,7 @@ function SplitPaneView({
onCloseOtherTabs, onCloseOtherTabs,
onSelectNewTabOption, onSelectNewTabOption,
newTabAgentOptionId, newTabAgentOptionId,
onOpenPaneTab, buildPaneContentModel,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane, onFocusPane,
onReorderTabsInPane, onReorderTabsInPane,
renderPaneEmptyState, renderPaneEmptyState,
@@ -475,22 +464,64 @@ function SplitPaneView({
onDropPreviewChange, onDropPreviewChange,
}: SplitPaneViewProps) { }: SplitPaneViewProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]); const paneState = useMemo(
const paneModel = useMemo(
() => () =>
deriveWorkspaceTabModel({ deriveWorkspacePaneState({
tabs: paneTabs.map((tab) => ({ pane,
tabId: tab.tabId, tabs: uiTabs,
target: tab.target,
createdAt: 0,
})),
focusedTabId: pane.focusedTabId,
}), }),
[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 ( return (
<View <View
@@ -516,16 +547,11 @@ function SplitPaneView({
> >
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
paneId={pane.id} paneId={pane.id}
tabs={paneTabs} tabs={desktopTabRowItems}
activeTabKey={activeTabDescriptor?.key ?? ""}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey} setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab} onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab} onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
@@ -544,30 +570,8 @@ function SplitPaneView({
</View> </View>
<View style={styles.paneContent}> <View style={styles.paneContent}>
{activeTabDescriptor ? ( {paneContent ? (
<WorkspacePaneContent <WorkspacePaneContent content={paneContent} />
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,
});
}}
/>
) : ( ) : (
renderPaneEmptyState?.() ?? null renderPaneEmptyState?.() ?? null
)} )}
@@ -598,26 +602,6 @@ function collectPanesById(node: SplitNode): Map<string, SplitPane> {
return next; 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 { function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") { if (node.kind === "pane") {
return node.pane.id; return node.pane.id;
@@ -18,26 +18,30 @@ import {
WorkspaceTabIcon, WorkspaceTabIcon,
type WorkspaceTabPresentation, type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation"; } 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 type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__"; type NewTabOptionId = "__new_tab_agent__";
export interface WorkspaceDesktopTabRowItem {
tab: WorkspaceTabDescriptor;
isActive: boolean;
isCloseHovered: boolean;
isClosingTab: boolean;
}
type WorkspaceDesktopTabsRowProps = { type WorkspaceDesktopTabsRowProps = {
paneId?: string; paneId?: string;
tabs: WorkspaceTabDescriptor[]; tabs: WorkspaceDesktopTabRowItem[];
activeTabKey: string;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>; setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: 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; onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
@@ -65,87 +69,41 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
return "Agent"; 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({ function TabChip({
tab, tab,
index,
tabCount,
isActive, isActive,
resolvedTabWidth, resolvedTabWidth,
showLabel, showLabel,
showCloseButton, showCloseButton,
isCloseHovered, isCloseHovered,
isClosingTab, isClosingTab,
normalizedServerId, presentation,
normalizedWorkspaceId, tooltipLabel,
resolvedTab,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
onNavigateTab, onNavigateTab,
onCloseTab, onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
dragHandleProps, dragHandleProps,
}: { }: {
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
index: number;
tabCount: number;
isActive: boolean; isActive: boolean;
resolvedTabWidth: number; resolvedTabWidth: number;
showLabel: boolean; showLabel: boolean;
showCloseButton: boolean; showCloseButton: boolean;
isCloseHovered: boolean; isCloseHovered: boolean;
isClosingTab: boolean; isClosingTab: boolean;
normalizedServerId: string; presentation: WorkspaceTabPresentation;
normalizedWorkspaceId: string; tooltipLabel: string;
resolvedTab: WorkspaceDesktopTabActions;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>; setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>; setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void; onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | 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; dragHandleProps: any;
}) { }) {
const presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
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 ( return (
<ContextMenu key={tab.key}> <ContextMenu key={tab.key}>
@@ -214,7 +172,7 @@ function TabChip({
{showCloseButton ? ( {showCloseButton ? (
<Pressable <Pressable
testID={getCloseButtonTestId(tab)} testID={closeButtonTestId}
disabled={isClosingTab} disabled={isClosingTab}
onHoverIn={() => { onHoverIn={() => {
setHoveredTabKey(tab.key); setHoveredTabKey(tab.key);
@@ -280,15 +238,10 @@ function TabChip({
export function WorkspaceDesktopTabsRow({ export function WorkspaceDesktopTabsRow({
paneId, paneId,
tabs, tabs,
activeTabKey,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey, setHoveredTabKey,
setHoveredCloseTabKey, setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab, onNavigateTab,
onCloseTab, onCloseTab,
onCopyResumeCommand, onCopyResumeCommand,
@@ -334,7 +287,7 @@ export function WorkspaceDesktopTabsRow({
const tabLabelLengths = useMemo( const tabLabelLengths = useMemo(
() => () =>
tabs.map((tab) => { tabs.map((tab) => {
const label = getFallbackTabLabel(tab); const label = getFallbackTabLabel(tab.tab);
return label.length; return label.length;
}), }),
[tabs] [tabs]
@@ -365,10 +318,10 @@ export function WorkspaceDesktopTabsRow({
> >
<SortableInlineList <SortableInlineList
data={tabs} data={tabs}
keyExtractor={(tab) => tab.key} keyExtractor={(tab) => `${tab.tab.key}:${tab.tab.kind}`}
useDragHandle useDragHandle
disabled={tabs.length < 2} disabled={tabs.length < 2}
onDragEnd={onReorderTabs} onDragEnd={(nextTabs) => onReorderTabs(nextTabs.map((tab) => tab.tab))}
externalDndContext={externalDndContext} externalDndContext={externalDndContext}
activeId={activeDragTabId} activeId={activeDragTabId}
getItemData={ getItemData={
@@ -376,52 +329,36 @@ export function WorkspaceDesktopTabsRow({
? (tab) => ({ ? (tab) => ({
kind: "workspace-tab", kind: "workspace-tab",
paneId, paneId,
tabId: tab.tabId, tabId: tab.tab.tabId,
}) })
: undefined : undefined
} }
renderItem={({ item: tab, index, dragHandleProps }) => { renderItem={({ item, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.target.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.target.agentId,
});
const isClosingTerminal =
tab.target.kind === "terminal" &&
killTerminalPending &&
killTerminalId === tab.target.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const 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;
return ( return (
<TabChip <ResolvedDesktopTabChip
key={tab.key} key={`${item.tab.key}:${item.tab.kind}`}
tab={tab} item={item}
index={index} index={index}
tabCount={tabs.length} tabCount={tabs.length}
isActive={isActive}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
isCloseHovered={isCloseHovered}
isClosingTab={isClosingTab}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand} onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId} onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
dragHandleProps={dragHandleProps} 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) => ({ const styles = StyleSheet.create((theme) => ({
tabsContainer: { tabsContainer: {
borderBottomWidth: 1, borderBottomWidth: 1,
@@ -1,10 +1,17 @@
import type { ComponentType } from "react";
import invariant from "tiny-invariant"; 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 { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels"; import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; 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; tab: WorkspaceTabDescriptor;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
@@ -14,7 +21,7 @@ export interface WorkspacePaneContentProps {
onOpenWorkspaceFile: (filePath: string) => void; onOpenWorkspaceFile: (filePath: string) => void;
} }
export function WorkspacePaneContent({ export function buildWorkspacePaneContentModel({
tab, tab,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
@@ -22,26 +29,36 @@ export function WorkspacePaneContent({
onCloseCurrentTab, onCloseCurrentTab,
onRetargetCurrentTab, onRetargetCurrentTab,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: WorkspacePaneContentProps) { }: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
ensurePanelsRegistered(); ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind); const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${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 ( return (
<PaneProvider <PaneProvider value={paneContextValue}>
value={{ <Component key={key} />
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> </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 { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
collectAllTabs, collectAllTabs,
findPaneById,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -69,6 +66,10 @@ import {
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import { import {
@@ -95,10 +96,11 @@ import {
import { import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
} from "@/screens/workspace/workspace-agent-visibility"; } from "@/screens/workspace/workspace-agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
import { import {
deriveWorkspaceTabModel, buildWorkspacePaneContentModel,
} from "@/screens/workspace/workspace-tab-model"; WorkspacePaneContent,
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; } from "@/screens/workspace/workspace-pane-content";
import { import {
buildBulkCloseConfirmationMessage, buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs, classifyBulkClosableTabs,
@@ -109,11 +111,6 @@ const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
interface FocusedPaneTabState {
tabs: WorkspaceTab[];
focusedTabId: string | null;
}
type WorkspaceScreenProps = { type WorkspaceScreenProps = {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -177,82 +174,6 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
return tab.target.path; 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 = { type MobileWorkspaceTabSwitcherProps = {
tabs: WorkspaceTabDescriptor[]; tabs: WorkspaceTabDescriptor[];
activeTabKey: string; activeTabKey: string;
@@ -873,7 +794,7 @@ function WorkspaceScreenContent({
const focusedPaneTabState = useMemo( const focusedPaneTabState = useMemo(
() => () =>
getFocusedPaneTabState({ deriveWorkspacePaneState({
layout: workspaceLayout, layout: workspaceLayout,
tabs: uiTabs, tabs: uiTabs,
}), }),
@@ -886,14 +807,19 @@ function WorkspaceScreenContent({
return null; return null;
} }
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedTarget) {
return null;
}
const existingTab = const existingTab =
uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null; uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
if (existingTab) { if (existingTab) {
return existingTab.tabId; return existingTab.tabId;
} }
const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState); const previousFocusedTabId = focusedPaneTabState.activeTabId;
const tabId = openWorkspaceTab(persistenceKey, target); const tabId = openWorkspaceTab(persistenceKey, normalizedTarget);
if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) { if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) {
focusWorkspaceTab(persistenceKey, previousFocusedTabId); focusWorkspaceTab(persistenceKey, previousFocusedTabId);
} }
@@ -908,10 +834,11 @@ function WorkspaceScreenContent({
return null; return null;
} }
const target: WorkspaceTabTarget = { const target = normalizeWorkspaceTabTarget({
kind: "draft", kind: "draft",
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(), draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
}; });
invariant(target?.kind === "draft", "Draft tab target must be valid");
if (input?.focus === false) { if (input?.focus === false) {
return ensureWorkspaceTab(target); return ensureWorkspaceTab(target);
} }
@@ -989,6 +916,24 @@ function WorkspaceScreenContent({
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
? openIntent ? openIntent
: null; : 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(() => { useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) { if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
@@ -1051,25 +996,7 @@ function WorkspaceScreenContent({
workspaceAgents, workspaceAgents,
]); ]);
const tabModel = useMemo( const activeTabId = resolvedPaneTabState.activeTabId;
() =>
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;
useEffect(() => { useEffect(() => {
if (!activeTabId || !persistenceKey) { if (!activeTabId || !persistenceKey) {
@@ -1078,11 +1005,11 @@ function WorkspaceScreenContent({
focusWorkspaceTab(persistenceKey, activeTabId); focusWorkspaceTab(persistenceKey, activeTabId);
}, [activeTabId, focusWorkspaceTab, persistenceKey]); }, [activeTabId, focusWorkspaceTab, persistenceKey]);
const activeTab = tabModel.activeTab; const activeTab = resolvedPaneTabState.activeTab;
const tabs = useMemo<WorkspaceTabDescriptor[]>( const tabs = useMemo<WorkspaceTabDescriptor[]>(
() => tabModel.tabs.map((tab) => tab.descriptor), () => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
[tabModel.tabs] [resolvedPaneTabState.tabs]
); );
const navigateToTabId = useCallback( const navigateToTabId = useCallback(
@@ -1625,13 +1552,13 @@ function WorkspaceScreenContent({
return true; return true;
} }
const focusedPane = getFocusedPane(workspaceLayout); const focusedPane = focusedPaneTabState.pane;
if (!focusedPane) { if (!focusedPane) {
return true; return true;
} }
if (action.id === "workspace.pane.split.right") { if (action.id === "workspace.pane.split.right") {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) { if (activePaneTabId) {
splitWorkspacePane(persistenceKey, { splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId, tabId: activePaneTabId,
@@ -1643,7 +1570,7 @@ function WorkspaceScreenContent({
} }
if (action.id === "workspace.pane.split.down") { if (action.id === "workspace.pane.split.down") {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); const activePaneTabId = focusedPaneTabState.activeTabId;
if (activePaneTabId) { if (activePaneTabId) {
splitWorkspacePane(persistenceKey, { splitWorkspacePane(persistenceKey, {
tabId: activePaneTabId, tabId: activePaneTabId,
@@ -1688,7 +1615,7 @@ function WorkspaceScreenContent({
direction === "up" || direction === "up" ||
direction === "down" direction === "down"
) { ) {
const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); const activePaneTabId = focusedPaneTabState.activeTabId;
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction); const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
if (activePaneTabId && adjacentPaneId) { if (activePaneTabId && adjacentPaneId) {
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId); moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
@@ -1712,6 +1639,8 @@ function WorkspaceScreenContent({
moveWorkspaceTabToPane, moveWorkspaceTabToPane,
persistenceKey, persistenceKey,
splitWorkspacePane, splitWorkspacePane,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
workspaceLayout, workspaceLayout,
] ]
); );
@@ -1752,6 +1681,67 @@ function WorkspaceScreenContent({
}); });
const activeTabDescriptor = activeTab?.descriptor ?? null; 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({ const content = shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
@@ -1772,34 +1762,7 @@ function WorkspaceScreenContent({
</View> </View>
) )
) : ( ) : (
<WorkspacePaneContent <WorkspacePaneContent content={activePaneContent!} />
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 });
}}
/>
); );
return ( return (
@@ -2034,23 +1997,13 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabsInPane} onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption} onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID} newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
onOpenPaneTab={({ paneId, target }) => { buildPaneContentModel={({ paneId, tab }) =>
focusWorkspacePane(persistenceKey, paneId); buildPaneContentModel({
const tabId = openWorkspaceTab(persistenceKey, target); tab,
if (tabId) { paneId,
navigateToTabId(tabId); focusPaneBeforeOpen: true,
} })
}} }
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);
}
}}
onFocusPane={(paneId) => { onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId); focusWorkspacePane(persistenceKey, paneId);
}} }}
@@ -1,4 +1,5 @@
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
export type WorkspaceTabMenuSurface = "desktop" | "mobile"; export type WorkspaceTabMenuSurface = "desktop" | "mobile";
@@ -31,6 +32,24 @@ interface BuildWorkspaceTabMenuEntriesInput {
onCloseOtherTabs: (tabId: string) => Promise<void> | void; 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 { function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
return surface === "mobile" ? "Close tabs above" : "Close to the left"; 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"; 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( export function buildWorkspaceTabMenuEntries(
input: BuildWorkspaceTabMenuEntriesInput input: BuildWorkspaceTabMenuEntriesInput
): WorkspaceTabMenuEntry[] { ): WorkspaceTabMenuEntry[] {
@@ -136,3 +168,26 @@ export function buildWorkspaceTabMenuEntries(
return entries; 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" }); 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", () => { it("keeps retargeted tab ids stable while matching upgraded targets", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
tabs: [ tabs: [
@@ -1,9 +1,11 @@
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 {
deriveWorkspacePaneState,
export interface WorkspaceDerivedTab { type WorkspaceDerivedTab,
descriptor: WorkspaceTabDescriptor; } from "@/screens/workspace/workspace-pane-state";
} import {
buildDeterministicWorkspaceTabId,
} from "@/utils/workspace-tab-identity";
export interface WorkspaceTabModel { export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[]; tabs: WorkspaceDerivedTab[];
@@ -11,102 +13,8 @@ export interface WorkspaceTabModel {
activeTab: WorkspaceDerivedTab | null; 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 { export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") { return buildDeterministicWorkspaceTabId(target);
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
return `file_${target.path}`;
} }
export function deriveWorkspaceTabModel(input: { export function deriveWorkspaceTabModel(input: {
@@ -114,51 +22,14 @@ export function deriveWorkspaceTabModel(input: {
focusedTabId?: string | null; focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null; preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel { }): WorkspaceTabModel {
const tabs: WorkspaceDerivedTab[] = []; const paneState = deriveWorkspacePaneState({
const openTabIds = new Set<string>(); tabs: input.tabs,
focusedTabId: input.focusedTabId,
for (const tab of input.tabs) { preferredTarget: input.preferredTarget,
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;
return { return {
tabs, tabs: paneState.tabs,
activeTabId, activeTabId: paneState.activeTabId,
activeTab, 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(); 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") vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111") .mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); .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 nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot); const nextGroup = expectGroup(nextRoot);
const nestedGroup = expectGroup(nextGroup.group.children[1]!);
expect(nextGroup.group.direction).toBe("horizontal"); expect(nextGroup.group.direction).toBe("horizontal");
expect(nextGroup.group.children).toHaveLength(3); expect(nextGroup.group.children).toHaveLength(2);
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]); 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([ expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
"left", "left",
"right", "right",
@@ -519,12 +523,33 @@ describe("workspace-layout-store actions", () => {
const rootGroup = expectGroup(layout.root); const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); 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(layout.focusedPaneId).toBe(paneCId);
expect(rootGroup.group.direction).toBe("horizontal"); expect(rootGroup.group.direction).toBe("horizontal");
expect(rootGroup.group.children).toEqual([ expect(rootGroup.group.children).toHaveLength(2);
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }), expect(
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }), 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]); 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", () => { it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID") vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee") .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 workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
@@ -583,17 +609,18 @@ describe("workspace-layout-store actions", () => {
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root; const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const splitGroup = expectGroup(splitRoot); 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 resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const resizedGroup = expectGroup(resizedRoot); 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(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff"); expect(farRightPaneId).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1); expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1); expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
expect(total).toBeCloseTo(1, 10); 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 { defineConfig, configDefaults } from "vitest/config";
import path from "path"; import path from "path";
import fs from "fs";
const appNodeModules = path.resolve(__dirname, "node_modules"); const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = 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({ export default defineConfig({
test: { test: {
@@ -45,10 +52,10 @@ export default defineConfig({
find: "react-native", find: "react-native",
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"), 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", find: "react-dom",
replacement: path.resolve(appNodeModules, "react-dom"), replacement: resolvePackageEntry("react-dom"),
}, },
], ],
}, },