mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add split-pane layout store and navigation shortcuts
This commit is contained in:
341
docs/PANEL-REFACTOR-PLAN.md
Normal file
341
docs/PANEL-REFACTOR-PLAN.md
Normal 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
docs/SPLIT-PANES-PLAN.md
Normal file
275
docs/SPLIT-PANES-PLAN.md
Normal 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
|
||||
Reference in New Issue
Block a user