refactor: improve split pane drag-and-drop and resize handle UX

This commit is contained in:
Mohamed Boudra
2026-03-18 16:19:43 +07:00
parent 0bc903fa21
commit b5212a69c9
9 changed files with 365 additions and 176 deletions
+2 -2
View File
@@ -752,11 +752,11 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot, workspaceRoot,
disableOuterSpacing, disableOuterSpacing,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme } = useUnistyles(); const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing); useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo( const markdownParser = useMemo(
() => { () => {
+73 -37
View File
@@ -26,12 +26,15 @@ export function ResizeHandle({
}: ResizeHandleProps) { }: ResizeHandleProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null); const pointerStateRef = useRef<PointerState | null>(null);
const [hovered, setHovered] = useState(false); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false);
const highlighted = active || dragging;
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: any) => { (event: any) => {
const handleElement = event.currentTarget as HTMLElement | null; const hitAreaElement = event.currentTarget as HTMLElement | null;
const containerElement = handleElement?.parentElement ?? null; const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!containerElement) { if (!containerElement) {
return; return;
} }
@@ -42,6 +45,8 @@ export function ResizeHandle({
return; return;
} }
setDragging(true);
pointerStateRef.current = { pointerStateRef.current = {
containerSize, containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY, pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
@@ -56,6 +61,7 @@ export function ResizeHandle({
function cleanup() { function cleanup() {
pointerStateRef.current = null; pointerStateRef.current = null;
setDragging(false);
document.body.style.cursor = previousCursor; document.body.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
@@ -90,65 +96,95 @@ export function ResizeHandle({
return ( return (
<View <View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[ style={[
styles.handle, styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical, direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
hovered && { { backgroundColor: theme.colors.border },
backgroundColor: theme.colors.surface2,
},
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]} ]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
setHovered(true);
}}
onPointerLeave={() => {
setHovered(false);
}}
> >
{highlighted && (
<View
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
)}
<View <View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[ style={[
styles.handleGrip, styles.hitArea,
direction === "horizontal" ? styles.handleGripHorizontal : styles.handleGripVertical, direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{ {
backgroundColor: hovered ? theme.colors.accent : theme.colors.border, cursor: direction === "horizontal" ? "col-resize" : "row-resize",
}, } as any,
]} ]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}}
onPointerLeave={() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}}
/> />
</View> </View>
); );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((_theme) => ({
handle: { handle: {
position: "relative", position: "relative",
alignItems: "center",
justifyContent: "center",
flexShrink: 0, flexShrink: 0,
backgroundColor: "transparent",
}, },
handleHorizontal: { handleHorizontal: {
width: 4, width: 1,
alignSelf: "stretch", alignSelf: "stretch",
}, },
handleVertical: { handleVertical: {
height: 4, height: 1,
width: "100%", width: "100%",
}, },
handleGrip: { highlight: {
opacity: 0.6, position: "absolute",
borderRadius: theme.borderRadius.full, zIndex: 5,
}, },
handleGripHorizontal: { highlightHorizontal: {
width: 2, top: 0,
height: "100%", bottom: 0,
width: 3,
left: -1,
}, },
handleGripVertical: { highlightVertical: {
width: "100%", left: 0,
height: 2, 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,
}, },
})); }));
@@ -34,6 +34,7 @@ function SortableItem<T>({
useDragHandle, useDragHandle,
disabled, disabled,
itemData, itemData,
externalDndContext,
}: { }: {
id: string; id: string;
item: T; item: T;
@@ -43,6 +44,7 @@ function SortableItem<T>({
useDragHandle: boolean; useDragHandle: boolean;
disabled: boolean; disabled: boolean;
itemData?: Record<string, unknown>; itemData?: Record<string, unknown>;
externalDndContext: boolean;
}): ReactElement { }): ReactElement {
const { const {
attributes, attributes,
@@ -59,17 +61,21 @@ function SortableItem<T>({
// This is a no-op but matches the mobile API // 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. // When using an external DndContext (e.g. split pane container), hide the
const baseTransform = CSS.Transform.toString( // original item during drag so the DragOverlay renders the floating copy.
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform // In standalone mode, keep the existing in-place drag visual.
); const baseTransform = externalDndContext && isDragging
const scaleTransform = isDragging ? "scale(1.01)" : ""; ? 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 combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = { const style = {
transform: combinedTransform || undefined, transform: combinedTransform || undefined,
transition, transition,
opacity: isDragging ? 0.9 : 1, opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1, zIndex: isDragging ? 1000 : 1,
}; };
@@ -198,6 +204,7 @@ export function SortableInlineList<T>({
useDragHandle={useDragHandle} useDragHandle={useDragHandle}
disabled={disabled} disabled={disabled}
itemData={getItemData?.(item, index)} itemData={getItemData?.(item, index)}
externalDndContext={externalDndContext}
/> />
); );
})} })}
+192 -46
View File
@@ -1,6 +1,7 @@
import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import { import {
DndContext, DndContext,
DragOverlay,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
closestCenter, closestCenter,
@@ -9,13 +10,19 @@ import {
useSensors, useSensors,
type CollisionDetection, type CollisionDetection,
type DragEndEvent, type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; 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 { 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,
resolveSplitDropPosition,
type SplitDropZoneHover,
} from "@/components/split-drop-zone";
import { import {
deriveWorkspacePaneState, deriveWorkspacePaneState,
getWorkspacePaneDescriptors, getWorkspacePaneDescriptors,
@@ -28,8 +35,12 @@ import {
WorkspaceDesktopTabsRow, WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem, type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row"; } 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 { 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"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
interface SplitContainerProps { interface SplitContainerProps {
@@ -51,7 +62,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void; onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__"; newTabAgentOptionId?: "__new_tab_agent__";
buildPaneContentModel: (input: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
@@ -81,36 +92,34 @@ interface SplitPaneDropData {
} }
interface SplitNodeViewProps interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout" | "workspaceKey"> { extends Omit<SplitContainerProps, "layout"> {
node: SplitNode; node: SplitNode;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
focusedPaneId: string; focusedPaneId: string;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
dropPreview: SplitDropZoneHover | null; dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
} }
interface SplitPaneViewProps interface SplitPaneViewProps
extends Omit< extends Omit<
SplitNodeViewProps, SplitNodeViewProps,
| "node" | "node"
| "workspaceKey"
| "focusedPaneId" | "focusedPaneId"
| "activeDragTabId" | "activeDragTabId"
| "showDropZones" | "showDropZones"
| "dropPreview" | "dropPreview"
| "onDropPreviewChange"
| "onSplitPane" | "onSplitPane"
| "onMoveTabToPane" | "onMoveTabToPane"
| "onResizeSplit" | "onResizeSplit"
> { > {
pane: SplitPane; pane: SplitPane;
uiTabs: WorkspaceTab[]; uiTabs: WorkspaceTab[];
focused: boolean; isFocused: boolean;
activeDragTabId: string | null; activeDragTabId: string | null;
showDropZones: boolean; showDropZones: boolean;
dropPreview: SplitDropZoneHover | null; dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
} }
const dropCollisionDetection: CollisionDetection = (args) => { const dropCollisionDetection: CollisionDetection = (args) => {
@@ -134,6 +143,7 @@ const dropCollisionDetection: CollisionDetection = (args) => {
export function SplitContainer({ export function SplitContainer({
layout, layout,
workspaceKey,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
uiTabs, uiTabs,
@@ -191,6 +201,64 @@ export function SplitContainer({
setDropPreview(null); setDropPreview(null);
}, []); }, []);
const updateDropPreview = useCallback(
(
event:
| Pick<DragMoveEvent, "active" | "over">
| Pick<DragOverEvent, "active" | "over">
) => {
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( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined; const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
@@ -266,11 +334,14 @@ export function SplitContainer({
sensors={sensors} sensors={sensors}
collisionDetection={dropCollisionDetection} collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel} onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SplitNodeView <SplitNodeView
node={layout.root} node={layout.root}
workspaceKey={workspaceKey}
uiTabs={uiTabs} uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId} focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
@@ -300,14 +371,96 @@ export function SplitContainer({
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null} showDropZones={activeDragTabId !== null}
dropPreview={dropPreview} dropPreview={dropPreview}
onDropPreviewChange={setDropPreview}
/> />
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext> </DndContext>
); );
} }
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 (
<DragOverlayTabChipInner
tab={descriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
);
}
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 (
<View
style={[
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
]}
>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[
styles.dragOverlayLabel,
{ color: theme.colors.foreground },
]}
>
{label}
</Text>
</View>
);
}
function SplitNodeView({ function SplitNodeView({
node, node,
workspaceKey,
uiTabs, uiTabs,
focusedPaneId, focusedPaneId,
normalizedServerId, normalizedServerId,
@@ -337,14 +490,13 @@ function SplitNodeView({
activeDragTabId, activeDragTabId,
showDropZones, showDropZones,
dropPreview, dropPreview,
onDropPreviewChange,
}: SplitNodeViewProps) { }: SplitNodeViewProps) {
if (node.kind === "pane") { if (node.kind === "pane") {
return ( return (
<SplitPaneView <SplitPaneView
pane={node.pane} pane={node.pane}
uiTabs={uiTabs} uiTabs={uiTabs}
focused={node.pane.id === focusedPaneId} isFocused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey} hoveredCloseTabKey={hoveredCloseTabKey}
@@ -369,11 +521,14 @@ function SplitNodeView({
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/> />
); );
} }
const groupSizes =
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
node.group.sizes;
return ( return (
<View <View
style={[ style={[
@@ -383,9 +538,10 @@ function SplitNodeView({
> >
{node.group.children.map((child, index) => ( {node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}> <Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}> <View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
<SplitNodeView <SplitNodeView
node={child} node={child}
workspaceKey={workspaceKey}
uiTabs={uiTabs} uiTabs={uiTabs}
focusedPaneId={focusedPaneId} focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
@@ -415,7 +571,6 @@ function SplitNodeView({
activeDragTabId={activeDragTabId} activeDragTabId={activeDragTabId}
showDropZones={showDropZones} showDropZones={showDropZones}
dropPreview={dropPreview} dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/> />
</View> </View>
{index < node.group.children.length - 1 ? ( {index < node.group.children.length - 1 ? (
@@ -423,7 +578,7 @@ function SplitNodeView({
direction={node.group.direction} direction={node.group.direction}
groupId={node.group.id} groupId={node.group.id}
index={index} index={index}
sizes={node.group.sizes} sizes={groupSizes}
onResizeSplit={onResizeSplit} onResizeSplit={onResizeSplit}
/> />
) : null} ) : null}
@@ -436,7 +591,7 @@ function SplitNodeView({
function SplitPaneView({ function SplitPaneView({
pane, pane,
uiTabs, uiTabs,
focused, isFocused,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
hoveredCloseTabKey, hoveredCloseTabKey,
@@ -461,7 +616,6 @@ function SplitPaneView({
activeDragTabId, activeDragTabId,
showDropZones, showDropZones,
dropPreview, dropPreview,
onDropPreviewChange,
}: SplitPaneViewProps) { }: SplitPaneViewProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const paneState = useMemo( const paneState = useMemo(
@@ -525,28 +679,15 @@ function SplitPaneView({
return ( return (
<View <View
style={[ style={styles.pane}
styles.pane,
{
borderColor: focused ? theme.colors.borderAccent : theme.colors.border,
},
]}
onPointerDownCapture={() => { onPointerDownCapture={() => {
onFocusPane(pane.id); onFocusPane(pane.id);
}} }}
> >
<View <View style={styles.paneTabs}>
style={[
styles.paneTabs,
focused
? {
borderTopColor: theme.colors.accent,
}
: null,
]}
>
<WorkspaceDesktopTabsRow <WorkspaceDesktopTabsRow
paneId={pane.id} paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems} tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId} normalizedWorkspaceId={normalizedWorkspaceId}
@@ -575,14 +716,8 @@ function SplitPaneView({
) : ( ) : (
renderPaneEmptyState?.() ?? null renderPaneEmptyState?.() ?? null
)} )}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View> </View>
<SplitDropZone
paneId={pane.id}
active={showDropZones}
preview={dropPreview}
onHoverChange={onDropPreviewChange}
/>
</View> </View>
); );
} }
@@ -632,16 +767,27 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderWidth: 1,
overflow: "hidden", overflow: "hidden",
}, },
paneTabs: { paneTabs: {},
borderTopWidth: 2,
borderTopColor: "transparent",
},
paneContent: { paneContent: {
position: "relative",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
minHeight: 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,
},
})); }));
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react"; import { useMemo } from "react";
import { useDroppable } from "@dnd-kit/core"; 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"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom"; export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
@@ -14,12 +14,6 @@ export interface SplitDropZoneProps {
paneId: string; paneId: string;
active: boolean; active: boolean;
preview: SplitDropZoneHover | null; preview: SplitDropZoneHover | null;
onHoverChange: (hover: SplitDropZoneHover | null) => void;
}
interface LayoutSize {
width: number;
height: number;
} }
const EDGE_RATIO = 0.15; const EDGE_RATIO = 0.15;
@@ -33,10 +27,8 @@ export function SplitDropZone({
paneId, paneId,
active, active,
preview, preview,
onHoverChange,
}: SplitDropZoneProps) { }: SplitDropZoneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [layoutSize, setLayoutSize] = useState<LayoutSize>({ width: 0, height: 0 });
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
id: buildSplitDropZoneId(paneId), id: buildSplitDropZoneId(paneId),
disabled: !active, 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(() => { const previewStyle = useMemo(() => {
if (!preview || preview.paneId !== paneId) { if (!preview || preview.paneId !== paneId) {
return null; return null;
@@ -95,21 +59,14 @@ export function SplitDropZone({
<View <View
ref={setNodeRef as any} ref={setNodeRef as any}
style={[styles.overlay, isOver && styles.overlayActive]} style={[styles.overlay, isOver && styles.overlayActive]}
onLayout={handleLayout} pointerEvents="none"
onPointerEnter={updateHover}
onPointerMove={updateHover}
onPointerLeave={() => {
if (preview?.paneId === paneId) {
onHoverChange(null);
}
}}
> >
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null} {previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
</View> </View>
); );
} }
function resolveDropPosition(input: { export function resolveSplitDropPosition(input: {
width: number; width: number;
height: number; height: number;
x: number; x: number;
@@ -174,12 +131,12 @@ const styles = StyleSheet.create((theme) => ({
}, },
overlayActive: { overlayActive: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
opacity: 0.02, opacity: 0.03,
}, },
preview: { preview: {
position: "absolute", position: "absolute",
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
opacity: 0.16, opacity: 0.2,
}, },
previewLeft: { previewLeft: {
left: 0, left: 0,
@@ -27,6 +27,10 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
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__";
type NewTabSelection = {
optionId: NewTabOptionId;
paneId?: string;
};
export interface WorkspaceDesktopTabRowItem { export interface WorkspaceDesktopTabRowItem {
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
@@ -37,6 +41,7 @@ export interface WorkspaceDesktopTabRowItem {
type WorkspaceDesktopTabsRowProps = { type WorkspaceDesktopTabsRowProps = {
paneId?: string; paneId?: string;
isFocused?: boolean;
tabs: WorkspaceDesktopTabRowItem[]; tabs: WorkspaceDesktopTabRowItem[];
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
@@ -49,7 +54,7 @@ type WorkspaceDesktopTabsRowProps = {
onCloseTabsToLeft: (tabId: string) => Promise<void> | void; onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void; onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onSelectNewTabOption: (optionId: NewTabOptionId) => void; onSelectNewTabOption: (selection: NewTabSelection) => void;
newTabAgentOptionId: NewTabOptionId; newTabAgentOptionId: NewTabOptionId;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
externalDndContext?: boolean; externalDndContext?: boolean;
@@ -72,6 +77,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
function TabChip({ function TabChip({
tab, tab,
isActive, isActive,
isFocused,
resolvedTabWidth, resolvedTabWidth,
showLabel, showLabel,
showCloseButton, showCloseButton,
@@ -88,6 +94,7 @@ function TabChip({
}: { }: {
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
isActive: boolean; isActive: boolean;
isFocused: boolean;
resolvedTabWidth: number; resolvedTabWidth: number;
showLabel: boolean; showLabel: boolean;
showCloseButton: boolean; showCloseButton: boolean;
@@ -104,6 +111,8 @@ function TabChip({
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered;
return ( return (
<ContextMenu key={tab.key}> <ContextMenu key={tab.key}>
@@ -119,13 +128,13 @@ function TabChip({
width: resolvedTabWidth, width: resolvedTabWidth,
maxWidth: resolvedTabWidth, maxWidth: resolvedTabWidth,
}, },
isActive && styles.tabActive,
!isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered,
]} ]}
onHoverIn={() => { onHoverIn={() => {
setHovered(true);
setHoveredTabKey(tab.key); setHoveredTabKey(tab.key);
}} }}
onHoverOut={() => { onHoverOut={() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current)); setHoveredTabKey((current) => (current === tab.key ? null : current));
}} }}
onPressIn={() => { onPressIn={() => {
@@ -136,6 +145,14 @@ function TabChip({
}} }}
accessibilityLabel={tooltipLabel} accessibilityLabel={tooltipLabel}
> >
{isActive && (
<View
style={[
styles.tabFocusIndicator,
!isFocused && styles.tabFocusIndicatorUnfocused,
]}
/>
)}
<View <View
{...(dragHandleProps?.attributes as any)} {...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)} {...(dragHandleProps?.listeners as any)}
@@ -143,7 +160,7 @@ function TabChip({
style={styles.tabHandle} style={styles.tabHandle}
> >
<View style={styles.tabIcon}> <View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} /> <WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
</View> </View>
{showLabel ? ( {showLabel ? (
presentation.titleState === "loading" ? ( presentation.titleState === "loading" ? (
@@ -157,7 +174,7 @@ function TabChip({
<Text <Text
style={[ style={[
styles.tabLabel, styles.tabLabel,
isActive && styles.tabLabelActive, isHighlighted && styles.tabLabelActive,
showCloseButton && styles.tabLabelWithCloseButton, showCloseButton && styles.tabLabelWithCloseButton,
]} ]}
selectable={false} selectable={false}
@@ -237,6 +254,7 @@ function TabChip({
export function WorkspaceDesktopTabsRow({ export function WorkspaceDesktopTabsRow({
paneId, paneId,
isFocused = false,
tabs, tabs,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
@@ -273,8 +291,8 @@ export function WorkspaceDesktopTabsRow({
() => ({ () => ({
rowHorizontalInset: 0, rowHorizontalInset: 0,
actionsReservedWidth: Math.max(0, tabsActionsWidth), actionsReservedWidth: Math.max(0, tabsActionsWidth),
rowPaddingHorizontal: theme.spacing[2], rowPaddingHorizontal: 0,
tabGap: theme.spacing[1], tabGap: 0,
maxTabWidth: 200, maxTabWidth: 200,
tabIconWidth: 14, tabIconWidth: 14,
tabHorizontalPadding: theme.spacing[3], tabHorizontalPadding: theme.spacing[3],
@@ -343,6 +361,7 @@ export function WorkspaceDesktopTabsRow({
<ResolvedDesktopTabChip <ResolvedDesktopTabChip
key={`${item.tab.key}:${item.tab.kind}`} key={`${item.tab.key}:${item.tab.kind}`}
item={item} item={item}
isFocused={isFocused}
index={index} index={index}
tabCount={tabs.length} tabCount={tabs.length}
normalizedServerId={normalizedServerId} normalizedServerId={normalizedServerId}
@@ -367,9 +386,14 @@ export function WorkspaceDesktopTabsRow({
</ScrollView> </ScrollView>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}> <View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
testID="workspace-new-agent-tab" testID="workspace-new-agent-tab"
onPress={() => onSelectNewTabOption(newTabAgentOptionId)} onPress={() =>
onSelectNewTabOption({
optionId: newTabAgentOptionId,
paneId,
})
}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="New agent tab" accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
@@ -393,6 +417,7 @@ export function WorkspaceDesktopTabsRow({
function ResolvedDesktopTabChip({ function ResolvedDesktopTabChip({
item, item,
isFocused,
index, index,
tabCount, tabCount,
normalizedServerId, normalizedServerId,
@@ -412,6 +437,7 @@ function ResolvedDesktopTabChip({
dragHandleProps, dragHandleProps,
}: { }: {
item: WorkspaceDesktopTabRowItem; item: WorkspaceDesktopTabRowItem;
isFocused: boolean;
index: number; index: number;
tabCount: number; tabCount: number;
normalizedServerId: string; normalizedServerId: string;
@@ -467,6 +493,7 @@ function ResolvedDesktopTabChip({
<TabChip <TabChip
tab={item.tab} tab={item.tab}
isActive={item.isActive} isActive={item.isActive}
isFocused={isFocused}
resolvedTabWidth={resolvedTabWidth} resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel} showLabel={showLabel}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
@@ -504,22 +531,19 @@ const styles = StyleSheet.create((theme) => ({
}, },
tabsContent: { tabsContent: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "stretch",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
}, },
tabsActions: { tabsActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingRight: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
}, },
tab: { tab: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRightWidth: 1,
borderRightColor: theme.colors.border,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
@@ -536,11 +560,16 @@ const styles = StyleSheet.create((theme) => ({
tabIcon: { tabIcon: {
flexShrink: 0, flexShrink: 0,
}, },
tabActive: { tabFocusIndicator: {
backgroundColor: theme.colors.surface3, position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: theme.colors.accent,
}, },
tabHovered: { tabFocusIndicatorUnfocused: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.borderAccent,
}, },
tabLabel: { tabLabel: {
flexShrink: 1, flexShrink: 1,
@@ -183,7 +183,10 @@ type MobileWorkspaceTabSwitcherProps = {
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void; 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> | void; onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void; onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
@@ -405,9 +408,13 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
<View style={styles.mobileTabsActions}> <View style={styles.mobileTabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
testID="workspace-new-agent-tab" testID="workspace-new-agent-tab"
onPress={() => onSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)} onPress={() =>
onSelectNewTabOption({
optionId: NEW_TAB_AGENT_OPTION_ID,
})
}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="New agent tab" accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
@@ -1143,12 +1150,15 @@ function WorkspaceScreenContent({
); );
const handleSelectNewTabOption = useCallback( const handleSelectNewTabOption = useCallback(
(key: typeof NEW_TAB_AGENT_OPTION_ID) => { (selection: { optionId: typeof NEW_TAB_AGENT_OPTION_ID; paneId?: string }) => {
if (key === NEW_TAB_AGENT_OPTION_ID) { if (selection.optionId === NEW_TAB_AGENT_OPTION_ID) {
if (selection.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, selection.paneId);
}
handleCreateDraftTab(); handleCreateDraftTab();
} }
}, },
[handleCreateDraftTab] [focusWorkspacePane, handleCreateDraftTab, persistenceKey]
); );
const runCloseFlowForTab = useCallback( const runCloseFlowForTab = useCallback(
@@ -302,7 +302,7 @@ function normalizeSizes(input: NormalizeSizesInput): number[] {
return sanitized.map((value) => value / total); return sanitized.map((value) => value / total);
} }
function clampNormalizedSizes(sizes: number[]): number[] { export function clampNormalizedSizes(sizes: number[]): number[] {
if (sizes.length === 0) { if (sizes.length === 0) {
return []; return [];
} }
@@ -7,6 +7,7 @@ import {
type WorkspaceTabTarget, type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store"; } from "@/stores/workspace-tabs-store";
import { import {
clampNormalizedSizes,
closeTabInLayout, closeTabInLayout,
collectAllPanes, collectAllPanes,
collectAllTabs, collectAllTabs,
@@ -24,7 +25,6 @@ import {
removeTabFromTree, removeTabFromTree,
reorderFocusedPaneTabsInLayout, reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout, reorderPaneTabsInLayout,
resizeSplitInLayout,
retargetTabInLayout, retargetTabInLayout,
splitPaneInLayout, splitPaneInLayout,
type SplitGroup, type SplitGroup,
@@ -51,6 +51,7 @@ export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
interface WorkspaceLayoutStore { interface WorkspaceLayoutStore {
layoutByWorkspace: Record<string, WorkspaceLayout>; layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void; closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void;
@@ -89,6 +90,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {},
openTab: (workspaceKey, target) => { openTab: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target); const normalizedTarget = normalizeWorkspaceTabTarget(target);
@@ -308,13 +310,12 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
} }
set((state) => ({ set((state) => ({
layoutByWorkspace: { splitSizesByWorkspace: {
...state.layoutByWorkspace, ...state.splitSizesByWorkspace,
[normalizedWorkspaceKey]: resizeSplitInLayout({ [normalizedWorkspaceKey]: {
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), ...(state.splitSizesByWorkspace[normalizedWorkspaceKey] ?? {}),
groupId: normalizedGroupId, [normalizedGroupId]: clampNormalizedSizes(sizes),
sizes, },
}),
}, },
})); }));
}, },
@@ -353,7 +354,10 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
for (const key in state.layoutByWorkspace) { for (const key in state.layoutByWorkspace) {
layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]);
} }
return { layoutByWorkspace }; return {
layoutByWorkspace,
splitSizesByWorkspace: state.splitSizesByWorkspace,
};
}, },
} }
) )