Add split-pane layout store and navigation shortcuts

This commit is contained in:
Mohamed Boudra
2026-03-18 01:36:54 +07:00
parent 30dd54a318
commit 30a225ce9f
22 changed files with 4710 additions and 375 deletions
+341
View File
@@ -0,0 +1,341 @@
# Panel Interface Refactor Plan
**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels.
## The Problem
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in:
- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status)
- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators
Every new panel type requires editing 3+ files. This must become a registry where panels self-register.
## Target Architecture
### 1. PanelRegistration Interface
```typescript
// panels/panel-registry.ts
interface PanelDescriptor {
label: string;
subtitle: string;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
}
interface PanelRegistration<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
component: React.ComponentType;
useDescriptor(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: { serverId: string; workspaceId: string },
): Promise<boolean>;
}
```
### 2. Panel Registry
```typescript
const panelRegistry = new Map<string, PanelRegistration>();
function registerPanel(registration: PanelRegistration): void {
panelRegistry.set(registration.kind, registration);
}
function getPanelRegistration(kind: string): PanelRegistration | undefined {
return panelRegistry.get(kind);
}
```
### 3. PaneContext
Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props.
```typescript
interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
```
### 4. WorkspaceTabTarget stays unchanged
```typescript
type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
```
No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target.
## Panel Implementations
Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly.
### Agent Panel Example
```typescript
// panels/agent-panel.ts
function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const agent = useSessionStore(
(s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null,
);
const provider = agent?.provider ?? "codex";
const label = resolveAgentLabel(agent?.title);
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: agentIconForProvider(provider),
statusBucket: agent ? deriveAgentStatusBucket(agent) : null,
};
}
function AgentPanel() {
const { serverId, target, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
return (
<AgentReadyScreen
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
onOpenWorkspaceFile={openFileInWorkspace}
/>
);
}
export const agentPanelRegistration: PanelRegistration<"agent"> = {
kind: "agent",
component: AgentPanel,
useDescriptor: useAgentPanelDescriptor,
async confirmClose(target, context) {
const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId);
if (agent?.status === "running") {
return confirmDialog({ title: "Agent is still running. Close anyway?" });
}
return true;
},
};
```
### Terminal Panel Example
```typescript
function useTerminalPanelDescriptor(
target: { kind: "terminal"; terminalId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
// read terminal data from appropriate store
return {
label: "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: TerminalIcon,
statusBucket: null,
};
}
function TerminalPanel() {
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
return (
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (terminalId) {
openTab({ kind: "terminal", terminalId });
}
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
```
### Draft Panel Example
```typescript
function useDraftPanelDescriptor(
_target: { kind: "draft"; draftId: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
return {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready",
icon: PencilIcon,
statusBucket: null,
};
}
function DraftPanel() {
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext();
invariant(target.kind === "draft", "DraftPanel requires draft target");
return (
<WorkspaceDraftAgentTab
serverId={serverId}
workspaceId={workspaceId}
tabId={tabId}
draftId={target.draftId}
onOpenWorkspaceFile={openFileInWorkspace}
onCreated={(agentSnapshot) => {
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
}}
/>
);
}
```
### File Panel Example
```typescript
function useFilePanelDescriptor(
target: { kind: "file"; path: string },
_context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
return {
label: fileName,
subtitle: target.path,
titleState: "ready",
icon: FileTextIcon,
statusBucket: null,
};
}
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "file", "FilePanel requires file target");
return (
<FilePane
serverId={serverId}
workspaceRoot={workspaceId}
filePath={target.path}
/>
);
}
```
## How the Tab Bar Uses It
Each tab chip calls the panel's `useDescriptor` hook:
```typescript
function TabChip({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
invariant(registration, `No panel registration for kind: ${target.kind}`);
const descriptor = registration.useDescriptor(target, { serverId, workspaceId });
return (
<TabChipChrome
tabId={tabId}
label={descriptor.label}
subtitle={descriptor.subtitle}
titleState={descriptor.titleState}
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
statusBucket={descriptor.statusBucket}
/>
);
}
```
## How the Workspace Screen Renders Content
Replaces the entire `renderContent()` switch:
```typescript
function PaneContent({ tabId, target, serverId, workspaceId }: {
tabId: string;
target: WorkspaceTabTarget;
serverId: string;
workspaceId: string;
}) {
const registration = getPanelRegistration(target.kind);
if (!registration) return null;
const Component = registration.component;
return (
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
<Component />
</PaneProvider>
);
}
```
## Implementation Steps
### Step 1: Create panel registry infrastructure
Create the following new files:
- `packages/app/src/panels/panel-registry.ts` — `PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()`
- `packages/app/src/panels/pane-context.ts` — `PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook
### Step 2: Create panel registration files
Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules:
- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose`
- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor`
- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor`
- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor`
- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each
### Step 3: Refactor workspace-tab-model.ts
Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`.
Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution.
### Step 4: Refactor workspace-screen.tsx renderContent()
Replace the `renderContent()` switch with `<PaneContent>` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen.
### Step 5: Refactor workspace-tab-presentation.tsx
Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor.
### Step 6: Verify
- `npm run typecheck` must pass
- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus
- Mobile tab switcher must work unchanged
- No visual regressions in tab bar, icons, status indicators
## Constraints
- **Pure refactor** — zero user-visible behavior changes
- **No new features** — no splits, no new panel types, no new keyboard shortcuts
- **WorkspaceTabTarget stays unchanged** — no store migration
- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor
- **Do not create index.ts barrel files** — project convention
- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds
- **Use `function` declarations** — project convention (no arrow function components)
- **Use `interface` over `type` where possible** — project convention
- **Run `npm run typecheck` after every change** — project rule
+275
View File
@@ -0,0 +1,275 @@
# Split Panes Plan
**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane).
## Data Model
### Core Types
```typescript
interface SplitPane {
id: string;
tabIds: string[];
focusedTabId: string | null;
}
interface SplitGroup {
id: string;
direction: "horizontal" | "vertical";
children: SplitNode[];
sizes: number[]; // proportional, sum to 1, same length as children
}
type SplitNode =
| { kind: "pane"; pane: SplitPane }
| { kind: "group"; group: SplitGroup };
interface WorkspaceLayout {
root: SplitNode;
focusedPaneId: string;
}
```
### Design Decisions
- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store.
- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane.
- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use.
- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering.
- **Max depth: 4 levels.**
- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%).
### Default State
Every workspace starts with:
```typescript
{
root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } },
focusedPaneId: "main",
}
```
### Migration
Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree.
## Store Actions
### Everyday Operations (pane-agnostic)
These don't take a `paneId`. Mobile code only uses these.
```typescript
openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null;
closeTab(workspaceKey: string, tabId: string): void;
focusTab(workspaceKey: string, tabId: string): void;
retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null;
reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane
getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes
```
- `openTab` creates the tab and adds it to the focused pane.
- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane.
- `focusTab` finds the tab in any pane, focuses it and focuses that pane.
### Split Operations (desktop only)
```typescript
splitPane(workspaceKey: string, input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}): string | null; // new pane ID, or null if depth cap hit
moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void;
focusPane(workspaceKey: string, paneId: string): void;
resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void;
reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void;
```
## Tree Transformations
### splitPane
**Position mapping:**
- `left` / `right` → `horizontal` direction
- `top` / `bottom` → `vertical` direction
- `left` / `top` → new pane inserted before target
- `right` / `bottom` → new pane inserted after target
**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat.
```
Before: horizontal([A, B])
Split B right with tab X
Optimized: horizontal([A, B, C]) ← insert into existing group
Naive: horizontal([A, horizontal([B, C])]) ← wastes depth
```
**Steps:**
1. Check depth — reject if would exceed 4 levels
2. Remove `tabId` from source pane (could be same or different pane)
3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }`
4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane
5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position)
6. If source pane is now empty → collapse it
7. Set `focusedPaneId` to new pane
### collapsePane
Triggered when a pane's last tab is removed or moved out.
```
Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3]
B loses last tab
After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized)
```
**Steps:**
1. Remove pane from parent group's children
2. Remove corresponding entry from parent's sizes
3. Renormalize sizes to sum to 1
4. If parent group now has 1 child → unwrap: replace group with its single remaining child
5. Unwrap can cascade up the tree
6. Move focus to nearest sibling
### moveTabToPane
Tab dragged from one pane to another existing pane.
1. Remove `tabId` from source pane's `tabIds`
2. Insert into target pane's `tabIds` at drop position (or end)
3. Set target pane's `focusedTabId` to the moved tab
4. If source pane is now empty → collapsePane
5. Set `focusedPaneId` to target pane
### resizeSplit
User drags a divider between panes.
1. Find group by ID
2. Update the two adjacent sizes based on drag delta
3. Clamp each child to minimum proportion (0.1)
4. Renormalize so sizes sum to 1
## Keyboard Shortcuts
| Action | Shortcut |
|---|---|
| Split right | `Cmd+\` |
| Split down | `Cmd+Shift+\` |
| Focus pane left | `Cmd+Shift+←` |
| Focus pane right | `Cmd+Shift+→` |
| Focus pane up | `Cmd+Shift+↑` |
| Focus pane down | `Cmd+Shift+↓` |
| Move tab to pane left | `Cmd+Shift+Alt+←` |
| Move tab to pane right | `Cmd+Shift+Alt+→` |
| Move tab to pane up | `Cmd+Shift+Alt+↑` |
| Move tab to pane down | `Cmd+Shift+Alt+↓` |
| Close pane | `Cmd+Shift+W` |
Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs.
## Drag and Drop UX
### Drop Zones
When dragging a tab over a pane, the pane is divided into 5 drop zones:
- **Center** (inner 40%) — move tab to this pane (add to existing tab list)
- **Left edge** (leftmost 15%) — split left
- **Right edge** (rightmost 15%) — split right
- **Top edge** (topmost 15%) — split up
- **Bottom edge** (bottommost 15%) — split down
### Overlay Preview
On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity.
### Cross-Pane Tab Drag
Tabs can be dragged:
- Within a pane's tab bar → reorder (existing behavior via SortableInlineList)
- From one pane's tab bar to another pane's tab bar → move tab to that pane
- From a tab bar to a pane's drop zone → split
When dragging the last tab out of a pane, the pane collapses after the drop completes.
## Implementation Steps
### Step 1: Layout Store
Create `packages/app/src/stores/workspace-layout-store.ts`:
- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types
- Zustand store with AsyncStorage persistence
- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs`
- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs`
- Version 6 migration from flat tab store
### Step 2: Migrate Workspace Screen to Layout Store
Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved.
### Step 3: Split Tree Transformations
Add to the layout store:
- `splitPane` with the parent-direction optimization and depth check
- `collapsePane` with unwrap cascading
- `moveTabToPane`
- `resizeSplit`
Pure tree transformation functions, tested independently.
### Step 4: Split Container Component
Create `packages/app/src/components/split-container.tsx`:
- Recursive component that renders `SplitNode`
- Groups render as flex containers with direction from `SplitGroup.direction`
- Panes render tab bar + active panel content (using the panel registry)
- Resize handles between children of a group
### Step 5: Drop Zones and Overlay
Create `packages/app/src/components/split-drop-zone.tsx`:
- Overlay that appears during tab drag
- Divides pane into 5 zones (center + 4 edges)
- Shows preview rectangle on hover
- Calls `splitPane` or `moveTabToPane` on drop
### Step 6: Cross-Pane Drag
Extend the existing dnd-kit setup:
- Tab bar items remain draggable (existing)
- Pane drop zones become droppable targets
- Tab bar of other panes become droppable targets (move to pane)
- DndContext wraps the entire split container (not individual panes)
### Step 7: Keyboard Shortcuts
Register new actions in `keyboard/actions.ts`:
- `workspace.pane.split.right`, `workspace.pane.split.down`
- `workspace.pane.focus.left/right/up/down`
- `workspace.pane.move-tab.left/right/up/down`
- `workspace.pane.close`
Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen.
### Step 8: Pane Focus Navigation
Implement spatial navigation for `focus.left/right/up/down`:
- Walk the tree to find the focused pane's position in the layout
- Find the nearest pane in the requested direction
- Focus it
Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`.
## Constraints
- Mobile stays single-pane — same store, no special casing
- Max 4 levels of nesting
- Minimum pane size: 10% of parent
- `PaneContext` interface unchanged — no `paneId` added
- Panel registry unchanged — panels don't know about splits
- Existing tab shortcuts work on focused pane, unchanged
@@ -0,0 +1,154 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
groupId: string;
index: number;
sizes: number[];
onResizeSplit: (groupId: string, sizes: number[]) => void;
}
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
export function ResizeHandle({
direction,
groupId,
index,
sizes,
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const [hovered, setHovered] = useState(false);
const handlePointerDown = useCallback(
(event: any) => {
const handleElement = event.currentTarget as HTMLElement | null;
const containerElement = handleElement?.parentElement ?? null;
if (!containerElement) {
return;
}
const rect = containerElement.getBoundingClientRect();
const containerSize = direction === "horizontal" ? rect.width : rect.height;
if (containerSize <= 0) {
return;
}
pointerStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
const previousCursor = document.body.style.cursor;
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
function cleanup() {
pointerStateRef.current = null;
document.body.style.cursor = previousCursor;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (!pointerState) {
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
}
function handlePointerUp() {
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
);
return (
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
hovered && {
backgroundColor: theme.colors.surface2,
},
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
setHovered(true);
}}
onPointerLeave={() => {
setHovered(false);
}}
>
<View
style={[
styles.handleGrip,
direction === "horizontal" ? styles.handleGripHorizontal : styles.handleGripVertical,
{
backgroundColor: hovered ? theme.colors.accent : theme.colors.border,
},
]}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
handle: {
position: "relative",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
backgroundColor: "transparent",
},
handleHorizontal: {
width: 4,
alignSelf: "stretch",
},
handleVertical: {
height: 4,
width: "100%",
},
handleGrip: {
opacity: 0.6,
borderRadius: theme.borderRadius.full,
},
handleGripHorizontal: {
width: 2,
height: "100%",
},
handleGripVertical: {
width: "100%",
height: 2,
},
}));
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
onDragEnd?: (data: T[]) => void;
useDragHandle?: boolean;
disabled?: boolean;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
return (
<>
@@ -33,6 +33,7 @@ function SortableItem<T>({
activeId,
useDragHandle,
disabled,
itemData,
}: {
id: string;
item: T;
@@ -41,6 +42,7 @@ function SortableItem<T>({
activeId: string | null;
useDragHandle: boolean;
disabled: boolean;
itemData?: Record<string, unknown>;
}): ReactElement {
const {
attributes,
@@ -50,7 +52,7 @@ function SortableItem<T>({
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
} = useSortable({ id, disabled, data: itemData });
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
@@ -107,6 +109,9 @@ export function SortableInlineList<T>({
disabled = false,
activationDistance = 8,
onDragBegin,
externalDndContext = false,
activeId: externalActiveId = null,
getItemData,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
@@ -116,10 +121,13 @@ export function SortableInlineList<T>({
disabled?: boolean;
activationDistance?: number;
onDragBegin?: () => void;
externalDndContext?: boolean;
activeId?: string | null;
getItemData?: (item: T, index: number) => Record<string, unknown>;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = dragItems ?? data;
const items = externalDndContext ? data : dragItems ?? data;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -175,6 +183,31 @@ export function SortableInlineList<T>({
const ids = items.map((item, index) => keyExtractor(item, index));
const renderedItems = (
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={externalDndContext ? externalActiveId : activeId}
useDragHandle={useDragHandle}
disabled={disabled}
itemData={getItemData?.(item, index)}
/>
);
})}
</SortableContext>
);
if (externalDndContext) {
return renderedItems;
}
return (
<DndContext
sensors={sensors}
@@ -183,23 +216,7 @@ export function SortableInlineList<T>({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
disabled={disabled}
/>
);
})}
</SortableContext>
{renderedItems}
</DndContext>
);
}
@@ -0,0 +1,663 @@
import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
pointerWithin,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
killTerminalPending: boolean;
killTerminalId: string | null;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (optionId: "__new_tab_agent__") => void;
newTabAgentOptionId?: "__new_tab_agent__";
onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void;
onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void;
onFocusPane: (paneId: string) => void;
onSplitPane: (input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
}
interface WorkspaceTabDragData {
kind: "workspace-tab";
paneId: string;
tabId: string;
}
interface SplitPaneDropData {
kind: "split-pane-drop";
paneId: string;
}
interface SplitNodeViewProps
extends Omit<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
node: SplitNode;
tabsById: Map<string, WorkspaceTab>;
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
}
interface SplitPaneViewProps
extends Omit<
SplitNodeViewProps,
| "node"
| "tabsById"
| "focusedPaneId"
| "activeDragTabId"
| "showDropZones"
| "dropPreview"
| "onDropPreviewChange"
| "onSplitPane"
| "onMoveTabToPane"
| "onResizeSplit"
> {
pane: SplitPane;
tabsById: Map<string, WorkspaceTab>;
focused: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
onDropPreviewChange: (hover: SplitDropZoneHover | null) => void;
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
);
if (tabHits.length > 0) {
return tabHits;
}
const paneHits = pointerHits.filter(
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
);
if (paneHits.length > 0) {
return paneHits;
}
return closestCenter(args);
};
export function SplitContainer({
layout,
normalizedServerId,
normalizedWorkspaceId,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId = "__new_tab_agent__",
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onSplitPane,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const tabsById = useMemo(() => {
const next = new Map<string, WorkspaceTab>();
for (const tab of uiTabs) {
next.set(tab.tabId, tab);
}
return next;
}, [uiTabs]);
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
setActiveDragTabId(null);
setDropPreview(null);
return;
}
setActiveDragTabId(data.tabId);
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragTabId(null);
setDropPreview(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
const overData = event.over?.data.current as
| WorkspaceTabDragData
| SplitPaneDropData
| undefined;
setActiveDragTabId(null);
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
return;
}
const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById);
const targetTabs = getPaneTabDescriptors(targetPane, tabsById);
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId);
if (sourceIndex < 0 || targetIndex < 0) {
setDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== targetIndex) {
const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex);
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
}
setDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(targetIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
return;
}
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
}
setDropPreview(null);
},
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById]
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={layout.root}
tabsById={tabsById}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
onDropPreviewChange={setDropPreview}
/>
</DndContext>
);
}
function SplitNodeView({
node,
tabsById,
focusedPaneId,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onSplitPane,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
onDropPreviewChange,
}: SplitNodeViewProps) {
if (node.kind === "pane") {
return (
<SplitPaneView
pane={node.pane}
tabsById={tabsById}
focused={node.pane.id === focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/>
);
}
return (
<View
style={[
styles.group,
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
]}
>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
<SplitNodeView
node={child}
tabsById={tabsById}
focusedPaneId={focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId}
onOpenPaneTab={onOpenPaneTab}
onRetargetTab={onRetargetTab}
onOpenWorkspaceFile={onOpenWorkspaceFile}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
onDropPreviewChange={onDropPreviewChange}
/>
</View>
{index < node.group.children.length - 1 ? (
<ResizeHandle
direction={node.group.direction}
groupId={node.group.id}
index={index}
sizes={node.group.sizes}
onResizeSplit={onResizeSplit}
/>
) : null}
</Fragment>
))}
</View>
);
}
function SplitPaneView({
pane,
tabsById,
focused,
normalizedServerId,
normalizedWorkspaceId,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
isArchivingAgent,
killTerminalPending,
killTerminalId,
onNavigateTab,
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
onOpenPaneTab,
onRetargetTab,
onOpenWorkspaceFile,
onFocusPane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
onDropPreviewChange,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]);
const paneModel = useMemo(
() =>
deriveWorkspaceTabModel({
tabs: paneTabs.map((tab) => ({
tabId: tab.tabId,
target: tab.target,
createdAt: 0,
})),
focusedTabId: pane.focusedTabId,
}),
[pane.focusedTabId, paneTabs]
);
const activeTabDescriptor = paneModel.activeTab
? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null
: null;
return (
<View
style={[
styles.pane,
{
borderColor: focused ? theme.colors.borderAccent : theme.colors.border,
},
]}
onPointerDownCapture={() => {
onFocusPane(pane.id);
}}
>
<View
style={[
styles.paneTabs,
focused
? {
borderTopColor: theme.colors.accent,
}
: null,
]}
>
<WorkspaceDesktopTabsRow
paneId={pane.id}
tabs={paneTabs}
activeTabKey={activeTabDescriptor?.key ?? ""}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
isArchivingAgent={isArchivingAgent}
killTerminalPending={killTerminalPending}
killTerminalId={killTerminalId}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
}}
externalDndContext
activeDragTabId={activeDragTabId}
/>
</View>
<View style={styles.paneContent}>
{activeTabDescriptor ? (
<WorkspacePaneContent
tab={activeTabDescriptor}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onOpenTab={(target) => {
onOpenPaneTab({
paneId: pane.id,
target,
});
}}
onCloseCurrentTab={() => {
void onCloseTab(activeTabDescriptor.tabId);
}}
onRetargetCurrentTab={(target) => {
onRetargetTab(activeTabDescriptor.tabId, target);
}}
onOpenWorkspaceFile={(filePath) => {
onOpenWorkspaceFile({
paneId: pane.id,
filePath,
});
}}
/>
) : (
renderPaneEmptyState?.() ?? null
)}
</View>
<SplitDropZone
paneId={pane.id}
active={showDropZones}
preview={dropPreview}
onHoverChange={onDropPreviewChange}
/>
</View>
);
}
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
const next = new Map<string, SplitPane>();
function visit(current: SplitNode) {
if (current.kind === "pane") {
next.set(current.pane.id, current.pane);
return;
}
for (const child of current.group.children) {
visit(child);
}
}
visit(node);
return next;
}
function getPaneTabDescriptors(
pane: SplitPane,
tabsById: Map<string, WorkspaceTab>
): WorkspaceTabDescriptor[] {
const next: WorkspaceTabDescriptor[] = [];
for (const tabId of pane.tabIds) {
const tab = tabsById.get(tabId);
if (!tab) {
continue;
}
next.push({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
});
}
return next;
}
function getNodeKey(node: SplitNode): string {
if (node.kind === "pane") {
return node.pane.id;
}
return node.group.id;
}
const styles = StyleSheet.create((theme) => ({
group: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
groupHorizontal: {
flexDirection: "row",
},
groupVertical: {
flexDirection: "column",
},
groupChild: {
flexBasis: 0,
minWidth: 0,
minHeight: 0,
},
pane: {
position: "relative",
flex: 1,
minWidth: 0,
minHeight: 0,
backgroundColor: theme.colors.surface0,
borderWidth: 1,
overflow: "hidden",
},
paneTabs: {
borderTopWidth: 2,
borderTopColor: "transparent",
},
paneContent: {
flex: 1,
minWidth: 0,
minHeight: 0,
},
}));
@@ -0,0 +1,214 @@
import { useCallback, useMemo, useState } from "react";
import { useDroppable } from "@dnd-kit/core";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom";
export interface SplitDropZoneHover {
paneId: string;
position: SplitDropZonePosition;
}
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;
const CENTER_RATIO = 0.4;
export function buildSplitDropZoneId(paneId: string): string {
return `split-pane-drop:${paneId}`;
}
export function SplitDropZone({
paneId,
active,
preview,
onHoverChange,
}: SplitDropZoneProps) {
const { theme } = useUnistyles();
const [layoutSize, setLayoutSize] = useState<LayoutSize>({ width: 0, height: 0 });
const { setNodeRef, isOver } = useDroppable({
id: buildSplitDropZoneId(paneId),
disabled: !active,
data: {
kind: "split-pane-drop",
paneId,
},
});
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;
}
return [
styles.preview,
getPreviewStyle(preview.position),
{
backgroundColor: theme.colors.accent,
},
];
}, [paneId, preview, theme.colors.accent]);
if (!active) {
return null;
}
return (
<View
ref={setNodeRef as any}
style={[styles.overlay, isOver && styles.overlayActive]}
onLayout={handleLayout}
onPointerEnter={updateHover}
onPointerMove={updateHover}
onPointerLeave={() => {
if (preview?.paneId === paneId) {
onHoverChange(null);
}
}}
>
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
</View>
);
}
function resolveDropPosition(input: {
width: number;
height: number;
x: number;
y: number;
}): SplitDropZonePosition {
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
const insideCenterX =
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
const insideCenterY =
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
if (insideCenterX && insideCenterY) {
return "center";
}
const edgeThresholdX = input.width * EDGE_RATIO;
const edgeThresholdY = input.height * EDGE_RATIO;
if (input.x <= edgeThresholdX) {
return "left";
}
if (input.x >= input.width - edgeThresholdX) {
return "right";
}
if (input.y <= edgeThresholdY) {
return "top";
}
if (input.y >= input.height - edgeThresholdY) {
return "bottom";
}
const distances = [
{ position: "left", distance: input.x },
{ position: "right", distance: input.width - input.x },
{ position: "top", distance: input.y },
{ position: "bottom", distance: input.height - input.y },
] satisfies Array<{ position: Exclude<SplitDropZonePosition, "center">; distance: number }>;
distances.sort((left, right) => left.distance - right.distance);
return distances[0]?.position ?? "center";
}
function getPreviewStyle(position: SplitDropZonePosition) {
if (position === "left") {
return styles.previewLeft;
}
if (position === "right") {
return styles.previewRight;
}
if (position === "top") {
return styles.previewTop;
}
if (position === "bottom") {
return styles.previewBottom;
}
return styles.previewCenter;
}
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 40,
},
overlayActive: {
backgroundColor: theme.colors.surface0,
opacity: 0.02,
},
preview: {
position: "absolute",
borderRadius: theme.borderRadius.md,
opacity: 0.16,
},
previewLeft: {
left: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewRight: {
right: 0,
top: 0,
bottom: 0,
width: "50%",
},
previewTop: {
left: 0,
top: 0,
right: 0,
height: "50%",
},
previewBottom: {
left: 0,
right: 0,
bottom: 0,
height: "50%",
},
previewCenter: {
left: "30%",
top: "30%",
right: "30%",
bottom: "30%",
},
}));
@@ -165,6 +165,21 @@ export function useKeyboardShortcuts({
scope: "workspace",
delta: input.payload.delta,
});
case "workspace.pane.split.right":
case "workspace.pane.split.down":
case "workspace.pane.focus.left":
case "workspace.pane.focus.right":
case "workspace.pane.focus.up":
case "workspace.pane.focus.down":
case "workspace.pane.move-tab.left":
case "workspace.pane.move-tab.right":
case "workspace.pane.move-tab.up":
case "workspace.pane.move-tab.down":
case "workspace.pane.close":
return keyboardActionDispatcher.dispatch({
id: input.action,
scope: "workspace",
});
case "workspace.navigate.index":
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
return false;
+11
View File
@@ -18,6 +18,17 @@ export type KeyboardActionId =
| "workspace.tab.close.current"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
| "workspace.pane.focus.right"
| "workspace.pane.focus.up"
| "workspace.pane.focus.down"
| "workspace.pane.move-tab.left"
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.navigate.index"
| "workspace.navigate.relative"
| "sidebar.toggle.left"
@@ -13,7 +13,18 @@ export type KeyboardActionId =
| "workspace.tab.new"
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative";
| "workspace.tab.navigate-relative"
| "workspace.pane.split.right"
| "workspace.pane.split.down"
| "workspace.pane.focus.left"
| "workspace.pane.focus.right"
| "workspace.pane.focus.up"
| "workspace.pane.focus.down"
| "workspace.pane.move-tab.left"
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close";
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
@@ -24,7 +35,18 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 };
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.left"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
export type KeyboardActionHandler = {
handlerId: string;
@@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isTauri: true },
action: "workspace.tab.close.current",
},
{
name: "matches Cmd+Backslash to split pane right on macOS",
event: { key: "\\", code: "Backslash", metaKey: true },
context: { isMac: true },
action: "workspace.pane.split.right",
},
{
name: "matches Cmd+Shift+Backslash to split pane down on macOS",
event: { key: "|", code: "Backslash", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.split.down",
},
{
name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS",
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.focus.right",
},
{
name: "matches Cmd+Shift+Alt+ArrowDown to move tab down on macOS",
event: {
key: "ArrowDown",
code: "ArrowDown",
metaKey: true,
shiftKey: true,
altKey: true,
},
context: { isMac: true },
action: "workspace.pane.move-tab.down",
},
{
name: "matches Cmd+Shift+W to close pane on macOS",
event: { key: "W", code: "KeyW", metaKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.pane.close",
},
{
name: "matches Cmd+B sidebar toggle on macOS",
event: { key: "b", code: "KeyB", metaKey: true },
@@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
},
{
name: "does not bind pane shortcuts on non-mac platforms",
event: { key: "\\", code: "Backslash", ctrlKey: true },
context: { isMac: false },
},
{
name: "keeps space typing available in message input",
event: { key: " ", code: "Space" },
@@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
@@ -289,6 +332,8 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["alt", "1-9"],
"workspace-tab-close-current": ["mod", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
},
{
@@ -71,6 +71,10 @@ function isMod(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey;
}
function isMacCommand(event: KeyboardEvent): boolean {
return event.metaKey && !event.ctrlKey;
}
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
@@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
keys: ["alt", "shift", "]"],
},
},
{
id: "workspace-pane-split-right-cmd-backslash",
action: "workspace.pane.split.right",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
!event.shiftKey &&
event.code === "Backslash",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-split-right",
section: "global",
label: "Split pane right",
keys: ["mod", "\\"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-split-down-cmd-shift-backslash",
action: "workspace.pane.split.down",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "Backslash",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-split-down",
section: "global",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-left-cmd-shift-left",
action: "workspace.pane.focus.left",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowLeft",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-left",
section: "global",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-right-cmd-shift-right",
action: "workspace.pane.focus.right",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowRight",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-right",
section: "global",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-up-cmd-shift-up",
action: "workspace.pane.focus.up",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowUp",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-up",
section: "global",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-focus-down-cmd-shift-down",
action: "workspace.pane.focus.down",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
event.code === "ArrowDown",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-focus-down",
section: "global",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
action: "workspace.pane.move-tab.left",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowLeft",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-left",
section: "global",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
action: "workspace.pane.move-tab.right",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowRight",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-right",
section: "global",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
action: "workspace.pane.move-tab.up",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowUp",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-up",
section: "global",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
action: "workspace.pane.move-tab.down",
matches: (event) =>
isMacCommand(event) &&
event.altKey &&
event.shiftKey &&
event.code === "ArrowDown",
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-move-tab-down",
section: "global",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
when: (context) => context.isMac,
},
},
{
id: "workspace-pane-close-cmd-shift-w",
action: "workspace.pane.close",
matches: (event) =>
isMacCommand(event) &&
!event.altKey &&
event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) =>
context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen,
help: {
id: "workspace-pane-close",
section: "global",
label: "Close pane",
keys: ["mod", "shift", "W"],
when: (context) => context.isMac,
},
},
{
id: "command-center-toggle",
action: "command-center.toggle",
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Plus, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -27,6 +27,7 @@ const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__";
type WorkspaceDesktopTabsRowProps = {
paneId?: string;
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
@@ -47,6 +48,8 @@ type WorkspaceDesktopTabsRowProps = {
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
newTabAgentOptionId: NewTabOptionId;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
externalDndContext?: boolean;
activeDragTabId?: string | null;
};
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
@@ -59,7 +62,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "file") {
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
}
return "";
return "Agent";
}
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
@@ -96,7 +99,6 @@ function TabChip({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onPresentationChange,
dragHandleProps,
}: {
tab: WorkspaceTabDescriptor;
@@ -119,19 +121,14 @@ function TabChip({
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
dragHandleProps: any;
}) {
const { theme } = useUnistyles();
const presentation = useWorkspaceTabPresentation({
tab,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
useEffect(() => {
onPresentationChange(tab.key, presentation);
}, [onPresentationChange, presentation, tab.key]);
const { theme } = useUnistyles();
const tooltipLabel =
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
@@ -281,6 +278,7 @@ function TabChip({
}
export function WorkspaceDesktopTabsRow({
paneId,
tabs,
activeTabKey,
normalizedServerId,
@@ -301,47 +299,12 @@ export function WorkspaceDesktopTabsRow({
onSelectNewTabOption,
newTabAgentOptionId,
onReorderTabs,
externalDndContext = false,
activeDragTabId = null,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [presentationsByKey, setPresentationsByKey] = useState<Map<string, WorkspaceTabPresentation>>(
() => new Map()
);
const handlePresentationChange = useCallback(
(tabKey: string, presentation: WorkspaceTabPresentation) => {
setPresentationsByKey((current) => {
const existing = current.get(tabKey);
if (
existing?.label === presentation.label &&
existing?.subtitle === presentation.subtitle &&
existing?.titleState === presentation.titleState &&
existing?.statusBucket === presentation.statusBucket &&
existing?.icon === presentation.icon
) {
return current;
}
const next = new Map(current);
next.set(tabKey, presentation);
return next;
});
},
[]
);
useEffect(() => {
setPresentationsByKey((current) => {
const next = new Map<string, WorkspaceTabPresentation>();
for (const tab of tabs) {
const presentation = current.get(tab.key);
if (presentation) {
next.set(tab.key, presentation);
}
}
return next.size === current.size ? current : next;
});
}, [tabs]);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
@@ -371,14 +334,10 @@ export function WorkspaceDesktopTabsRow({
const tabLabelLengths = useMemo(
() =>
tabs.map((tab) => {
const presentation = presentationsByKey.get(tab.key);
if (presentation?.titleState === "loading") {
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
}
const label = presentation?.label ?? getFallbackTabLabel(tab);
const label = getFallbackTabLabel(tab);
return label.length;
}),
[layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
[tabs]
);
const { layout } = useWorkspaceTabLayout({
@@ -410,6 +369,17 @@ export function WorkspaceDesktopTabsRow({
useDragHandle
disabled={tabs.length < 2}
onDragEnd={onReorderTabs}
externalDndContext={externalDndContext}
activeId={activeDragTabId}
getItemData={
paneId
? (tab) => ({
kind: "workspace-tab",
paneId,
tabId: tab.tabId,
})
: undefined
}
renderItem={({ item: tab, index, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const isCloseHovered = hoveredCloseTabKey === tab.key;
@@ -452,7 +422,6 @@ export function WorkspaceDesktopTabsRow({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onPresentationChange={handlePresentationChange}
dragHandleProps={dragHandleProps}
/>
);
@@ -0,0 +1,47 @@
import invariant from "tiny-invariant";
import { PaneProvider } from "@/panels/pane-context";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface WorkspacePaneContentProps {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
onOpenWorkspaceFile: (filePath: string) => void;
}
export function WorkspacePaneContent({
tab,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
onOpenWorkspaceFile,
}: WorkspacePaneContentProps) {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const Component = registration.component;
return (
<PaneProvider
value={{
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
retargetCurrentTab: onRetargetCurrentTab,
openFileInWorkspace: onOpenWorkspaceFile,
}}
>
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
</PaneProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,8 +23,7 @@ describe("deriveWorkspaceTabModel", () => {
];
const model = deriveWorkspaceTabModel({
tabs: uiTabs,
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
@@ -43,11 +42,10 @@ describe("deriveWorkspaceTabModel", () => {
it("applies stored order and appends unordered tabs deterministically", () => {
const model = deriveWorkspaceTabModel({
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
],
tabOrder: ["terminal_term-1", "agent_agent-b"],
});
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
@@ -63,7 +61,6 @@ describe("deriveWorkspaceTabModel", () => {
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
};
expect(
@@ -82,7 +79,6 @@ describe("deriveWorkspaceTabModel", () => {
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
focusedTabId: "agent_agent-a",
preferredTarget: { kind: "agent", agentId: "agent-b" },
});
@@ -100,7 +96,6 @@ describe("deriveWorkspaceTabModel", () => {
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
preferredTarget: { kind: "agent", agentId: "agent-1" },
});
@@ -123,7 +118,6 @@ describe("deriveWorkspaceTabModel", () => {
createdAt: 2,
},
],
tabOrder: [],
});
expect(model.tabs).toHaveLength(1);
@@ -111,52 +111,29 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
export function deriveWorkspaceTabModel(input: {
tabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
const tabs: WorkspaceDerivedTab[] = [];
const openTabIds = new Set<string>();
const normalizedTabs = input.tabs
.map((tab) => normalizeWorkspaceTab(tab))
.filter((tab): tab is WorkspaceTab => tab !== null)
.sort((left, right) => left.createdAt - right.createdAt);
for (const tab of input.tabs) {
const normalizedTab = normalizeWorkspaceTab(tab);
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
continue;
}
for (const tab of normalizedTabs) {
tabsById.set(tab.tabId, {
openTabIds.add(normalizedTab.tabId);
tabs.push({
descriptor: {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
key: normalizedTab.tabId,
tabId: normalizedTab.tabId,
kind: normalizedTab.target.kind,
target: normalizedTab.target,
},
});
}
const orderedTabIds: string[] = [];
const used = new Set<string>();
for (const tabId of input.tabOrder) {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
continue;
}
used.add(normalizedTabId);
orderedTabIds.push(normalizedTabId);
}
for (const tabId of tabsById.keys()) {
if (used.has(tabId)) {
continue;
}
used.add(tabId);
orderedTabIds.push(tabId);
}
const tabs = orderedTabIds
.map((tabId) => tabsById.get(tabId) ?? null)
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = input.preferredTarget ?? null;
const preferredTabId = (() => {
@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Check } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -34,15 +34,26 @@ export function useWorkspaceTabPresentation(input: {
workspaceId: input.workspaceId,
});
return {
key: input.tab.key,
kind: input.tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
};
return useMemo(
() => ({
key: input.tab.key,
kind: input.tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
}),
[
descriptor.icon,
descriptor.label,
descriptor.statusBucket,
descriptor.subtitle,
descriptor.titleState,
input.tab.key,
input.tab.kind,
]
);
}
type WorkspaceTabIconProps = {
@@ -0,0 +1,610 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllPanes,
collectAllTabs,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
getTreeDepth,
insertSplit,
removePaneFromTree,
removeTabFromTree,
useWorkspaceLayoutStore,
type SplitNode,
} from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
function createTab(tabId: string): WorkspaceTab {
return {
tabId,
target: { kind: "draft", draftId: tabId },
createdAt: 1,
};
}
function createPane(input: {
id: string;
tabIds: string[];
focusedTabId?: string | null;
}): SplitNode {
const tabs = input.tabIds.map((tabId) => createTab(tabId));
return {
kind: "pane",
pane: {
id: input.id,
tabIds: input.tabIds,
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
tabs,
} as any,
};
}
function createWorkspaceKey(): string {
const key = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
expect(key).toBeTruthy();
return key as string;
}
function expectGroup(node: SplitNode): Extract<SplitNode, { kind: "group" }> {
expect(node.kind).toBe("group");
return node as Extract<SplitNode, { kind: "group" }>;
}
describe("workspace-layout-store helpers", () => {
it("finds panes and tabs across nested groups", () => {
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.4, 0.6],
children: [
createPane({ id: "left", tabIds: ["tab-a", "tab-b"], focusedTabId: "tab-a" }),
{
kind: "group",
group: {
id: "group-right",
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPane({ id: "top-right", tabIds: ["tab-c"] }),
createPane({ id: "bottom-right", tabIds: ["tab-d"] }),
],
},
},
],
},
};
expect(findPaneById(root, "top-right")?.tabIds).toEqual(["tab-c"]);
expect(findPaneContainingTab(root, "tab-b")?.id).toBe("left");
expect(getTreeDepth(root)).toBe(3);
expect(collectAllPanes(root).map((pane) => pane.id)).toEqual([
"left",
"top-right",
"bottom-right",
]);
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual([
"tab-a",
"tab-b",
"tab-c",
"tab-d",
]);
});
});
describe("workspace-layout-store tree transforms", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("insertSplit reuses a matching parent direction instead of nesting", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.25, 0.75],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
createPane({ id: "right", tabIds: ["tab-b", "tab-c"] }),
],
},
};
const nextRoot = insertSplit(root, "right", "tab-c", "right");
const nextGroup = expectGroup(nextRoot);
expect(nextGroup.group.direction).toBe("horizontal");
expect(nextGroup.group.children).toHaveLength(3);
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
"left",
"right",
"pane_11111111-1111-1111-1111-111111111111",
]);
expect(findPaneById(nextRoot, "right")?.tabIds).toEqual(["tab-b"]);
expect(findPaneById(nextRoot, "pane_11111111-1111-1111-1111-111111111111")?.tabIds).toEqual([
"tab-c",
]);
});
it("removePaneFromTree unwraps single-child groups and renormalizes siblings", () => {
const root: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.2, 0.8],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
{
kind: "group",
group: {
id: "group-right",
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPane({ id: "top-right", tabIds: ["tab-b"] }),
createPane({ id: "bottom-right", tabIds: ["tab-c"] }),
],
},
},
],
},
};
const nextRoot = removePaneFromTree(root, "top-right");
const nextGroup = expectGroup(nextRoot);
expect(nextGroup.group.sizes).toEqual([0.2, 0.8]);
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual(["left", "bottom-right"]);
expect(nextGroup.group.children[1]).toEqual(createPane({ id: "bottom-right", tabIds: ["tab-c"] }));
});
it("removeTabFromTree collapses empty panes but keeps the final root pane", () => {
const splitRoot: SplitNode = {
kind: "group",
group: {
id: "group-root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
createPane({ id: "left", tabIds: ["tab-a"] }),
createPane({ id: "right", tabIds: ["tab-b"] }),
],
},
};
const collapsed = removeTabFromTree(splitRoot, "tab-a");
expect(collapsed).toEqual(createPane({ id: "right", tabIds: ["tab-b"] }));
const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] });
const emptied = removeTabFromTree(singlePaneRoot, "tab-a");
expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null }));
});
});
describe("workspace-layout-store actions", () => {
beforeEach(() => {
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
vi.restoreAllMocks();
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
store.focusPane(workspaceKey, "main");
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const fileTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const terminalTabId = store.openTab(workspaceKey, { kind: "terminal", terminalId: "term-1" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: terminalTabId!,
targetPaneId: "main",
position: "right",
});
store.focusTab(workspaceKey, fileTabId!);
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe("main");
store.focusTab(workspaceKey, terminalTabId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
});
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
kind: "agent",
agentId: "agent-1",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const splitPane = findPaneById(layout.root, splitPaneId!);
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
expect(nextTabId).toBe(secondTabId);
expect(splitPane?.tabIds).toEqual([secondTabId!]);
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
expect(retargetedTab).toEqual({
tabId: secondTabId,
target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number),
});
});
it("reorderTabs reorders tabs within the focused pane", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(findPaneById(layout.root, "main")).toEqual({
id: "main",
tabIds: [thirdTabId!, firstTabId!, secondTabId!],
focusedTabId: thirdTabId,
tabs: [
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
{ tabId: firstTabId, target: { kind: "file", path: "/repo/worktree/a.ts" }, createdAt: expect.any(Number) },
{ tabId: secondTabId, target: { kind: "file", path: "/repo/worktree/b.ts" }, createdAt: expect.any(Number) },
],
});
});
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"34343434-3434-3434-3434-343434343434"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const fourthTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: thirdTabId!,
targetPaneId: "main",
position: "right",
});
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
store.focusPane(workspaceKey, "main");
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, splitPaneId!)).toEqual({
id: splitPaneId,
tabIds: [fourthTabId!, thirdTabId!],
focusedTabId: fourthTabId,
tabs: [
{ tabId: fourthTabId, target: { kind: "file", path: "/repo/worktree/d.ts" }, createdAt: expect.any(Number) },
{ tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) },
],
});
});
it("focusPane switches workspace focus to a different pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"56565656-5656-5656-5656-565656565656"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.focusPane(workspaceKey, "main");
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe("main");
store.focusPane(workspaceKey, splitPaneId!);
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
expect(layout.focusedPaneId).toBe(splitPaneId);
});
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"cccccccc-cccc-cccc-cccc-cccccccccccc"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
expect(layout.focusedPaneId).toBe("main");
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
});
it("splitPane enforces the maximum depth of four", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const d = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" });
const e = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/e.ts" });
expect(a).toBeTruthy();
const pane1 = store.splitPane(workspaceKey, {
tabId: b!,
targetPaneId: "main",
position: "right",
});
const pane2 = store.splitPane(workspaceKey, {
tabId: c!,
targetPaneId: pane1!,
position: "bottom",
});
const pane3 = store.splitPane(workspaceKey, {
tabId: d!,
targetPaneId: pane2!,
position: "right",
});
const pane4 = store.splitPane(workspaceKey, {
tabId: e!,
targetPaneId: pane3!,
position: "bottom",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(pane4).toBeNull();
expect(getTreeDepth(layout.root)).toBe(4);
});
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"dddddddd-dddd-dddd-dddd-dddddddddddd"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const leftTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const rightTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: rightTabId!,
targetPaneId: "main",
position: "right",
});
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
expect(findPaneById(layout.root, splitPaneId!)?.tabIds).toEqual([
"file_/repo/worktree/b.ts",
"file_/repo/worktree/a.ts",
]);
});
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
const paneBId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
const paneCId = store.splitPane(workspaceKey, {
tabId: thirdTabId!,
targetPaneId: paneBId!,
position: "bottom",
});
store.closeTab(workspaceKey, secondTabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const rootGroup = expectGroup(layout.root);
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989");
expect(layout.focusedPaneId).toBe(paneCId);
expect(rootGroup.group.direction).toBe("horizontal");
expect(rootGroup.group.children).toEqual([
createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }),
createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }),
]);
expect(rootGroup.group.sizes).toEqual([0.5, 0.5]);
});
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"abababab-abab-abab-abab-abababababab"
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
store.focusPane(workspaceKey, "main");
const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
expect(a).toBeTruthy();
const rightPaneId = store.splitPane(workspaceKey, {
tabId: b!,
targetPaneId: "main",
position: "right",
});
const farRightPaneId = store.splitPane(workspaceKey, {
tabId: c!,
targetPaneId: rightPaneId!,
position: "right",
});
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const splitGroup = expectGroup(splitRoot);
store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]);
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root;
const resizedGroup = expectGroup(resizedRoot);
const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1);
expect(total).toBeCloseTo(1, 10);
});
it("closing the last tab keeps a single empty pane in the layout", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const tabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" });
store.closeTab(workspaceKey, tabId!);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(layout).toEqual(createDefaultLayout());
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { SplitNode } from "@/stores/workspace-layout-store";
import { findAdjacentPane } from "./split-navigation";
function createPaneNode(id: string): SplitNode {
return {
kind: "pane",
pane: {
id,
tabIds: [],
focusedTabId: null,
},
};
}
function createGroupNode(input: {
direction: "horizontal" | "vertical";
sizes: number[];
children: SplitNode[];
}): SplitNode {
return {
kind: "group",
group: {
id: `${input.direction}-group`,
direction: input.direction,
sizes: input.sizes,
children: input.children,
},
};
}
describe("findAdjacentPane", () => {
it("finds direct horizontal and vertical neighbors in nested layouts", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.25, 0.5, 0.25],
children: [
createPaneNode("left"),
createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [createPaneNode("top-middle"), createPaneNode("bottom-middle")],
}),
createPaneNode("right"),
],
});
expect(findAdjacentPane(root, "top-middle", "left")).toBe("left");
expect(findAdjacentPane(root, "top-middle", "right")).toBe("right");
expect(findAdjacentPane(root, "top-middle", "down")).toBe("bottom-middle");
expect(findAdjacentPane(root, "bottom-middle", "up")).toBe("top-middle");
});
it("returns null when there is no pane in the requested direction", () => {
const root = createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("left"), createPaneNode("right")],
});
expect(findAdjacentPane(root, "left", "left")).toBeNull();
expect(findAdjacentPane(root, "right", "right")).toBeNull();
expect(findAdjacentPane(root, "left", "up")).toBeNull();
});
it("prefers the closest overlapping pane when multiple candidates exist", () => {
const root = createGroupNode({
direction: "vertical",
sizes: [0.5, 0.5],
children: [
createPaneNode("top"),
createGroupNode({
direction: "horizontal",
sizes: [0.5, 0.5],
children: [createPaneNode("bottom-left"), createPaneNode("bottom-right")],
}),
],
});
expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left");
expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top");
});
});
+251
View File
@@ -0,0 +1,251 @@
import type { SplitNode } from "@/stores/workspace-layout-store";
const ROOT_MIN = 0;
const ROOT_MAX = 1;
const FLOAT_TOLERANCE = 0.000001;
export interface PaneBounds {
paneId: string;
left: number;
top: number;
right: number;
bottom: number;
centerX: number;
centerY: number;
}
interface PaneCandidate {
paneId: string;
primaryDistance: number;
secondaryDistance: number;
centerDistance: number;
overlap: number;
}
export function findAdjacentPane(
root: SplitNode,
focusedPaneId: string,
direction: "left" | "right" | "up" | "down"
): string | null {
const panes = collectPaneBounds(root, {
left: ROOT_MIN,
top: ROOT_MIN,
right: ROOT_MAX,
bottom: ROOT_MAX,
});
const focusedPane = panes.find((pane) => pane.paneId === focusedPaneId) ?? null;
if (!focusedPane) {
return null;
}
const candidates = panes
.filter((pane) => pane.paneId !== focusedPaneId)
.map((pane) => buildCandidate({ pane, focusedPane, direction }))
.filter((candidate): candidate is PaneCandidate => candidate !== null)
.sort(compareCandidates);
return candidates[0]?.paneId ?? null;
}
function compareCandidates(left: PaneCandidate, right: PaneCandidate): number {
if (left.primaryDistance !== right.primaryDistance) {
return left.primaryDistance - right.primaryDistance;
}
if (left.secondaryDistance !== right.secondaryDistance) {
return left.secondaryDistance - right.secondaryDistance;
}
if (left.overlap !== right.overlap) {
return right.overlap - left.overlap;
}
if (left.centerDistance !== right.centerDistance) {
return left.centerDistance - right.centerDistance;
}
return left.paneId.localeCompare(right.paneId);
}
function buildCandidate(input: {
pane: PaneBounds;
focusedPane: PaneBounds;
direction: "left" | "right" | "up" | "down";
}): PaneCandidate | null {
const { pane, focusedPane, direction } = input;
if (direction === "left") {
const primaryDistance = focusedPane.left - pane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "right") {
const primaryDistance = pane.left - focusedPane.right;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.top,
endA: pane.bottom,
startB: focusedPane.top,
endB: focusedPane.bottom,
}),
centerDistance: Math.abs(pane.centerY - focusedPane.centerY),
overlap,
};
}
if (direction === "up") {
const primaryDistance = focusedPane.top - pane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
const primaryDistance = pane.top - focusedPane.bottom;
if (primaryDistance < -FLOAT_TOLERANCE) {
return null;
}
const overlap = getOverlapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
});
return {
paneId: pane.paneId,
primaryDistance,
secondaryDistance: getGapLength({
startA: pane.left,
endA: pane.right,
startB: focusedPane.left,
endB: focusedPane.right,
}),
centerDistance: Math.abs(pane.centerX - focusedPane.centerX),
overlap,
};
}
function collectPaneBounds(
node: SplitNode,
bounds: { left: number; top: number; right: number; bottom: number }
): PaneBounds[] {
if (node.kind === "pane") {
return [
{
paneId: node.pane.id,
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
centerX: (bounds.left + bounds.right) / 2,
centerY: (bounds.top + bounds.bottom) / 2,
},
];
}
const panes: PaneBounds[] = [];
const totalWidth = bounds.right - bounds.left;
const totalHeight = bounds.bottom - bounds.top;
let offset = 0;
for (let index = 0; index < node.group.children.length; index += 1) {
const child = node.group.children[index];
const size = node.group.sizes[index] ?? 0;
if (node.group.direction === "horizontal") {
const childLeft = bounds.left + totalWidth * offset;
offset += size;
const childRight = bounds.left + totalWidth * offset;
panes.push(
...collectPaneBounds(child, {
left: childLeft,
top: bounds.top,
right: childRight,
bottom: bounds.bottom,
})
);
continue;
}
const childTop = bounds.top + totalHeight * offset;
offset += size;
const childBottom = bounds.top + totalHeight * offset;
panes.push(
...collectPaneBounds(child, {
left: bounds.left,
top: childTop,
right: bounds.right,
bottom: childBottom,
})
);
}
return panes;
}
function getGapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
if (endA < startB) {
return startB - endA;
}
if (endB < startA) {
return startA - endB;
}
return 0;
}
function getOverlapLength(input: {
startA: number;
endA: number;
startB: number;
endB: number;
}): number {
const { startA, endA, startB, endB } = input;
return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB));
}