From b5212a69c9387a453c0293a17a6c8072ed003ea6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Mar 2026 16:19:43 +0700 Subject: [PATCH] refactor: improve split pane drag-and-drop and resize handle UX --- packages/app/src/components/message.tsx | 4 +- packages/app/src/components/resize-handle.tsx | 110 +++++--- .../components/sortable-inline-list.web.tsx | 19 +- .../app/src/components/split-container.tsx | 238 ++++++++++++++---- .../app/src/components/split-drop-zone.tsx | 55 +--- .../workspace/workspace-desktop-tabs-row.tsx | 69 +++-- .../screens/workspace/workspace-screen.tsx | 22 +- .../src/stores/workspace-layout-actions.ts | 2 +- .../app/src/stores/workspace-layout-store.ts | 22 +- 9 files changed, 365 insertions(+), 176 deletions(-) diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index f1cf98b5a..4203bc7e9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -752,11 +752,11 @@ export const AssistantMessage = memo(function AssistantMessage({ workspaceRoot, disableOuterSpacing, }: AssistantMessageProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); + const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); const markdownParser = useMemo( () => { diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index f6cdf8eaa..cc46b5837 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -26,12 +26,15 @@ export function ResizeHandle({ }: ResizeHandleProps) { const { theme } = useUnistyles(); const pointerStateRef = useRef(null); - const [hovered, setHovered] = useState(false); + const hoverTimerRef = useRef | null>(null); + const [active, setActive] = useState(false); + const [dragging, setDragging] = useState(false); + const highlighted = active || dragging; const handlePointerDown = useCallback( (event: any) => { - const handleElement = event.currentTarget as HTMLElement | null; - const containerElement = handleElement?.parentElement ?? null; + const hitAreaElement = event.currentTarget as HTMLElement | null; + const containerElement = hitAreaElement?.parentElement?.parentElement ?? null; if (!containerElement) { return; } @@ -42,6 +45,8 @@ export function ResizeHandle({ return; } + setDragging(true); + pointerStateRef.current = { containerSize, pointerStart: direction === "horizontal" ? event.clientX : event.clientY, @@ -56,6 +61,7 @@ export function ResizeHandle({ function cleanup() { pointerStateRef.current = null; + setDragging(false); document.body.style.cursor = previousCursor; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); @@ -90,65 +96,95 @@ export function ResizeHandle({ return ( { - setHovered(true); - }} - onPointerLeave={() => { - setHovered(false); - }} > + {highlighted && ( + + )} { + hoverTimerRef.current = setTimeout(() => { + setActive(true); + }, 150); + }} + onPointerLeave={() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setActive(false); + }} /> ); } -const styles = StyleSheet.create((theme) => ({ +const styles = StyleSheet.create((_theme) => ({ handle: { position: "relative", - alignItems: "center", - justifyContent: "center", flexShrink: 0, - backgroundColor: "transparent", }, handleHorizontal: { - width: 4, + width: 1, alignSelf: "stretch", }, handleVertical: { - height: 4, + height: 1, width: "100%", }, - handleGrip: { - opacity: 0.6, - borderRadius: theme.borderRadius.full, + highlight: { + position: "absolute", + zIndex: 5, }, - handleGripHorizontal: { - width: 2, - height: "100%", + highlightHorizontal: { + top: 0, + bottom: 0, + width: 3, + left: -1, }, - handleGripVertical: { - width: "100%", - height: 2, + highlightVertical: { + left: 0, + right: 0, + height: 3, + top: -1, + }, + hitArea: { + position: "absolute", + zIndex: 10, + }, + hitAreaHorizontal: { + left: -5, + top: 0, + bottom: 0, + width: 10, + }, + hitAreaVertical: { + top: -5, + left: 0, + right: 0, + height: 10, }, })); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 15dc6a8dd..7b7556e1d 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -34,6 +34,7 @@ function SortableItem({ useDragHandle, disabled, itemData, + externalDndContext, }: { id: string; item: T; @@ -43,6 +44,7 @@ function SortableItem({ useDragHandle: boolean; disabled: boolean; itemData?: Record; + externalDndContext: boolean; }): ReactElement { const { attributes, @@ -59,17 +61,21 @@ function SortableItem({ // This is a no-op but matches the mobile API }, []); - // See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale. - const baseTransform = CSS.Transform.toString( - transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform - ); - const scaleTransform = isDragging ? "scale(1.01)" : ""; + // When using an external DndContext (e.g. split pane container), hide the + // original item during drag so the DragOverlay renders the floating copy. + // In standalone mode, keep the existing in-place drag visual. + const baseTransform = externalDndContext && isDragging + ? undefined + : CSS.Transform.toString( + transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform + ); + const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : ""; const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); const style = { transform: combinedTransform || undefined, transition, - opacity: isDragging ? 0.9 : 1, + opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1, zIndex: isDragging ? 1000 : 1, }; @@ -198,6 +204,7 @@ export function SortableInlineList({ useDragHandle={useDragHandle} disabled={disabled} itemData={getItemData?.(item, index)} + externalDndContext={externalDndContext} /> ); })} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index d9f7609f8..66798ca5f 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,6 +1,7 @@ import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { DndContext, + DragOverlay, KeyboardSensor, PointerSensor, closestCenter, @@ -9,13 +10,19 @@ import { useSensors, type CollisionDetection, type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { View } from "react-native"; +import { View, Text } 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 { + SplitDropZone, + resolveSplitDropPosition, + type SplitDropZoneHover, +} from "@/components/split-drop-zone"; import { deriveWorkspacePaneState, getWorkspacePaneDescriptors, @@ -28,8 +35,12 @@ import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + useWorkspaceTabPresentation, + WorkspaceTabIcon, +} from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; interface SplitContainerProps { @@ -51,7 +62,7 @@ interface SplitContainerProps { onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; - onSelectNewTabOption: (optionId: "__new_tab_agent__") => void; + onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void; newTabAgentOptionId?: "__new_tab_agent__"; buildPaneContentModel: (input: { paneId: string; @@ -81,36 +92,34 @@ interface SplitPaneDropData { } interface SplitNodeViewProps - extends Omit { + extends Omit { node: SplitNode; uiTabs: WorkspaceTab[]; focusedPaneId: string; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; - onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; } interface SplitPaneViewProps extends Omit< SplitNodeViewProps, | "node" + | "workspaceKey" | "focusedPaneId" | "activeDragTabId" | "showDropZones" | "dropPreview" - | "onDropPreviewChange" | "onSplitPane" | "onMoveTabToPane" | "onResizeSplit" > { pane: SplitPane; uiTabs: WorkspaceTab[]; - focused: boolean; + isFocused: boolean; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; - onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; } const dropCollisionDetection: CollisionDetection = (args) => { @@ -134,6 +143,7 @@ const dropCollisionDetection: CollisionDetection = (args) => { export function SplitContainer({ layout, + workspaceKey, normalizedServerId, normalizedWorkspaceId, uiTabs, @@ -191,6 +201,64 @@ export function SplitContainer({ setDropPreview(null); }, []); + const updateDropPreview = useCallback( + ( + event: + | Pick + | Pick + ) => { + const activeData = event.active.data.current as WorkspaceTabDragData | undefined; + const overData = event.over?.data.current as + | WorkspaceTabDragData + | SplitPaneDropData + | undefined; + + if (activeData?.kind !== "workspace-tab") { + setDropPreview(null); + return; + } + + if (overData?.kind !== "split-pane-drop") { + setDropPreview(null); + return; + } + + const translatedRect = event.active.rect.current.translated; + const overRect = event.over?.rect; + if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) { + setDropPreview(null); + return; + } + + const centerX = translatedRect.left + translatedRect.width / 2; + const centerY = translatedRect.top + translatedRect.height / 2; + const relativeX = centerX - overRect.left; + const relativeY = centerY - overRect.top; + if ( + Number.isNaN(relativeX) || + Number.isNaN(relativeY) || + relativeX < 0 || + relativeX > overRect.width || + relativeY < 0 || + relativeY > overRect.height + ) { + setDropPreview(null); + return; + } + + setDropPreview({ + paneId: overData.paneId, + position: resolveSplitDropPosition({ + width: overRect.width, + height: overRect.height, + x: relativeX, + y: relativeY, + }), + }); + }, + [] + ); + const handleDragEnd = useCallback( (event: DragEndEvent) => { const activeData = event.active.data.current as WorkspaceTabDragData | undefined; @@ -266,11 +334,14 @@ export function SplitContainer({ sensors={sensors} collisionDetection={dropCollisionDetection} onDragStart={handleDragStart} + onDragMove={updateDropPreview} + onDragOver={updateDropPreview} onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > + + {activeDragTabId ? ( + + ) : null} + ); } +function DragOverlayTabChip({ + tabId, + uiTabs, + normalizedServerId, + normalizedWorkspaceId, +}: { + tabId: string; + uiTabs: WorkspaceTab[]; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const tab = uiTabs.find((t) => t.tabId === tabId); + if (!tab) { + return null; + } + const descriptor: WorkspaceTabDescriptor = { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + }; + return ( + + ); +} + +function DragOverlayTabChipInner({ + tab, + normalizedServerId, + normalizedWorkspaceId, +}: { + tab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const { theme } = useUnistyles(); + const presentation = useWorkspaceTabPresentation({ + tab, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + const label = + presentation.titleState === "loading" ? "Loading..." : presentation.label; + + return ( + + + + {label} + + + ); +} + function SplitNodeView({ node, + workspaceKey, uiTabs, focusedPaneId, normalizedServerId, @@ -337,14 +490,13 @@ function SplitNodeView({ activeDragTabId, showDropZones, dropPreview, - onDropPreviewChange, }: SplitNodeViewProps) { if (node.kind === "pane") { return ( ); } + const groupSizes = + useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ?? + node.group.sizes; + return ( {node.group.children.map((child, index) => ( - + {index < node.group.children.length - 1 ? ( @@ -423,7 +578,7 @@ function SplitNodeView({ direction={node.group.direction} groupId={node.group.id} index={index} - sizes={node.group.sizes} + sizes={groupSizes} onResizeSplit={onResizeSplit} /> ) : null} @@ -436,7 +591,7 @@ function SplitNodeView({ function SplitPaneView({ pane, uiTabs, - focused, + isFocused, normalizedServerId, normalizedWorkspaceId, hoveredCloseTabKey, @@ -461,7 +616,6 @@ function SplitPaneView({ activeDragTabId, showDropZones, dropPreview, - onDropPreviewChange, }: SplitPaneViewProps) { const { theme } = useUnistyles(); const paneState = useMemo( @@ -525,28 +679,15 @@ function SplitPaneView({ return ( { onFocusPane(pane.id); }} > - + - - ); } @@ -632,16 +767,27 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, minHeight: 0, backgroundColor: theme.colors.surface0, - borderWidth: 1, overflow: "hidden", }, - paneTabs: { - borderTopWidth: 2, - borderTopColor: "transparent", - }, + paneTabs: {}, paneContent: { + position: "relative", flex: 1, minWidth: 0, minHeight: 0, }, + dragOverlayChip: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + maxWidth: 200, + }, + dragOverlayLabel: { + fontSize: theme.fontSize.sm, + flexShrink: 1, + }, })); diff --git a/packages/app/src/components/split-drop-zone.tsx b/packages/app/src/components/split-drop-zone.tsx index 4d0cb2c83..6bbab233b 100644 --- a/packages/app/src/components/split-drop-zone.tsx +++ b/packages/app/src/components/split-drop-zone.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useDroppable } from "@dnd-kit/core"; -import { View, type LayoutChangeEvent } from "react-native"; +import { View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom"; @@ -14,12 +14,6 @@ export interface SplitDropZoneProps { paneId: string; active: boolean; preview: SplitDropZoneHover | null; - onHoverChange: (hover: SplitDropZoneHover | null) => void; -} - -interface LayoutSize { - width: number; - height: number; } const EDGE_RATIO = 0.15; @@ -33,10 +27,8 @@ export function SplitDropZone({ paneId, active, preview, - onHoverChange, }: SplitDropZoneProps) { const { theme } = useUnistyles(); - const [layoutSize, setLayoutSize] = useState({ width: 0, height: 0 }); const { setNodeRef, isOver } = useDroppable({ id: buildSplitDropZoneId(paneId), disabled: !active, @@ -46,34 +38,6 @@ export function SplitDropZone({ }, }); - const handleLayout = useCallback((event: LayoutChangeEvent) => { - const width = Math.round(event.nativeEvent.layout.width); - const height = Math.round(event.nativeEvent.layout.height); - setLayoutSize((current) => - current.width === width && current.height === height ? current : { width, height } - ); - }, []); - - const updateHover = useCallback( - (event: any) => { - if (!active || layoutSize.width <= 0 || layoutSize.height <= 0) { - return; - } - const locationX = Number(event.nativeEvent.locationX ?? 0); - const locationY = Number(event.nativeEvent.locationY ?? 0); - onHoverChange({ - paneId, - position: resolveDropPosition({ - width: layoutSize.width, - height: layoutSize.height, - x: locationX, - y: locationY, - }), - }); - }, - [active, layoutSize.height, layoutSize.width, onHoverChange, paneId] - ); - const previewStyle = useMemo(() => { if (!preview || preview.paneId !== paneId) { return null; @@ -95,21 +59,14 @@ export function SplitDropZone({ { - if (preview?.paneId === paneId) { - onHoverChange(null); - } - }} + pointerEvents="none" > {previewStyle ? : null} ); } -function resolveDropPosition(input: { +export function resolveSplitDropPosition(input: { width: number; height: number; x: number; @@ -174,12 +131,12 @@ const styles = StyleSheet.create((theme) => ({ }, overlayActive: { backgroundColor: theme.colors.surface0, - opacity: 0.02, + opacity: 0.03, }, preview: { position: "absolute", borderRadius: theme.borderRadius.md, - opacity: 0.16, + opacity: 0.2, }, previewLeft: { left: 0, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 27e7b7eb5..e9de5a4f3 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -27,6 +27,10 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; type NewTabOptionId = "__new_tab_agent__"; +type NewTabSelection = { + optionId: NewTabOptionId; + paneId?: string; +}; export interface WorkspaceDesktopTabRowItem { tab: WorkspaceTabDescriptor; @@ -37,6 +41,7 @@ export interface WorkspaceDesktopTabRowItem { type WorkspaceDesktopTabsRowProps = { paneId?: string; + isFocused?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; @@ -49,7 +54,7 @@ type WorkspaceDesktopTabsRowProps = { onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; - onSelectNewTabOption: (optionId: NewTabOptionId) => void; + onSelectNewTabOption: (selection: NewTabSelection) => void; newTabAgentOptionId: NewTabOptionId; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; externalDndContext?: boolean; @@ -72,6 +77,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { function TabChip({ tab, isActive, + isFocused, resolvedTabWidth, showLabel, showCloseButton, @@ -88,6 +94,7 @@ function TabChip({ }: { tab: WorkspaceTabDescriptor; isActive: boolean; + isFocused: boolean; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; @@ -104,6 +111,8 @@ function TabChip({ }) { const { theme } = useUnistyles(); const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; + const [hovered, setHovered] = useState(false); + const isHighlighted = isActive || hovered || isCloseHovered; return ( @@ -119,13 +128,13 @@ function TabChip({ width: resolvedTabWidth, maxWidth: resolvedTabWidth, }, - isActive && styles.tabActive, - !isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered, ]} onHoverIn={() => { + setHovered(true); setHoveredTabKey(tab.key); }} onHoverOut={() => { + setHovered(false); setHoveredTabKey((current) => (current === tab.key ? null : current)); }} onPressIn={() => { @@ -136,6 +145,14 @@ function TabChip({ }} accessibilityLabel={tooltipLabel} > + {isActive && ( + + )} - + {showLabel ? ( presentation.titleState === "loading" ? ( @@ -157,7 +174,7 @@ function TabChip({ ({ rowHorizontalInset: 0, actionsReservedWidth: Math.max(0, tabsActionsWidth), - rowPaddingHorizontal: theme.spacing[2], - tabGap: theme.spacing[1], + rowPaddingHorizontal: 0, + tabGap: 0, maxTabWidth: 200, tabIconWidth: 14, tabHorizontalPadding: theme.spacing[3], @@ -343,6 +361,7 @@ export function WorkspaceDesktopTabsRow({ - onSelectNewTabOption(newTabAgentOptionId)} + onPress={() => + onSelectNewTabOption({ + optionId: newTabAgentOptionId, + paneId, + }) + } accessibilityRole="button" accessibilityLabel="New agent tab" style={({ hovered, pressed }) => [ @@ -393,6 +417,7 @@ export function WorkspaceDesktopTabsRow({ function ResolvedDesktopTabChip({ item, + isFocused, index, tabCount, normalizedServerId, @@ -412,6 +437,7 @@ function ResolvedDesktopTabChip({ dragHandleProps, }: { item: WorkspaceDesktopTabRowItem; + isFocused: boolean; index: number; tabCount: number; normalizedServerId: string; @@ -467,6 +493,7 @@ function ResolvedDesktopTabChip({ ({ }, tabsContent: { flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], + alignItems: "stretch", }, tabsActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingRight: theme.spacing[2], - paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRightWidth: 1, + borderRightColor: theme.colors.border, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], @@ -536,11 +560,16 @@ const styles = StyleSheet.create((theme) => ({ tabIcon: { flexShrink: 0, }, - tabActive: { - backgroundColor: theme.colors.surface3, + tabFocusIndicator: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: 2, + backgroundColor: theme.colors.accent, }, - tabHovered: { - backgroundColor: theme.colors.surface2, + tabFocusIndicatorUnfocused: { + backgroundColor: theme.colors.borderAccent, }, tabLabel: { flexShrink: 1, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 6e2dee66a..ced9b3f7c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -183,7 +183,10 @@ type MobileWorkspaceTabSwitcherProps = { normalizedServerId: string; normalizedWorkspaceId: string; onSelectSwitcherTab: (key: string) => void; - onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void; + onSelectNewTabOption: (selection: { + optionId: typeof NEW_TAB_AGENT_OPTION_ID; + paneId?: string; + }) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCloseTab: (tabId: string) => Promise | void; @@ -405,9 +408,13 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ - onSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)} + onPress={() => + onSelectNewTabOption({ + optionId: NEW_TAB_AGENT_OPTION_ID, + }) + } accessibilityRole="button" accessibilityLabel="New agent tab" style={({ hovered, pressed }) => [ @@ -1143,12 +1150,15 @@ function WorkspaceScreenContent({ ); const handleSelectNewTabOption = useCallback( - (key: typeof NEW_TAB_AGENT_OPTION_ID) => { - if (key === NEW_TAB_AGENT_OPTION_ID) { + (selection: { optionId: typeof NEW_TAB_AGENT_OPTION_ID; paneId?: string }) => { + if (selection.optionId === NEW_TAB_AGENT_OPTION_ID) { + if (selection.paneId && persistenceKey) { + focusWorkspacePane(persistenceKey, selection.paneId); + } handleCreateDraftTab(); } }, - [handleCreateDraftTab] + [focusWorkspacePane, handleCreateDraftTab, persistenceKey] ); const runCloseFlowForTab = useCallback( diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 5a034fd08..978914d3e 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -302,7 +302,7 @@ function normalizeSizes(input: NormalizeSizesInput): number[] { return sanitized.map((value) => value / total); } -function clampNormalizedSizes(sizes: number[]): number[] { +export function clampNormalizedSizes(sizes: number[]): number[] { if (sizes.length === 0) { return []; } diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 87a4e7f5d..09ad69600 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -7,6 +7,7 @@ import { type WorkspaceTabTarget, } from "@/stores/workspace-tabs-store"; import { + clampNormalizedSizes, closeTabInLayout, collectAllPanes, collectAllTabs, @@ -24,7 +25,6 @@ import { removeTabFromTree, reorderFocusedPaneTabsInLayout, reorderPaneTabsInLayout, - resizeSplitInLayout, retargetTabInLayout, splitPaneInLayout, type SplitGroup, @@ -51,6 +51,7 @@ export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout }; interface WorkspaceLayoutStore { layoutByWorkspace: Record; + splitSizesByWorkspace: Record>; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; @@ -89,6 +90,7 @@ export const useWorkspaceLayoutStore = create()( persist( (set, get) => ({ layoutByWorkspace: {}, + splitSizesByWorkspace: {}, openTab: (workspaceKey, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(target); @@ -308,13 +310,12 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: resizeSplitInLayout({ - layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), - groupId: normalizedGroupId, - sizes, - }), + splitSizesByWorkspace: { + ...state.splitSizesByWorkspace, + [normalizedWorkspaceKey]: { + ...(state.splitSizesByWorkspace[normalizedWorkspaceKey] ?? {}), + [normalizedGroupId]: clampNormalizedSizes(sizes), + }, }, })); }, @@ -353,7 +354,10 @@ export const useWorkspaceLayoutStore = create()( for (const key in state.layoutByWorkspace) { layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); } - return { layoutByWorkspace }; + return { + layoutByWorkspace, + splitSizesByWorkspace: state.splitSizesByWorkspace, + }; }, } )