refactor: improve split pane drag-and-drop and resize handle UX
This commit is contained in:
@@ -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(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user