# Flexible Workspace Panels Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add fold/unfold + drag-to-resize splitters for the desktop Sources (left) and Studio (right) panels in Investibook’s workspace page.

**Architecture:** Keep layout state local to the workspace page (desktop-only). Implement two draggable gutters that resize Sources/Studio while clamping to minimum widths and preserving a minimum collapsed rail width. Persist layout in `localStorage` keyed by `workspaceId`.

**Tech Stack:** Next.js App Router (client components), React 18, TypeScript, `@tidbcloud/uikit` (ActionIcon/Tooltip), inline styles.

---

## File Structure

**Create**
- `platform/investibook/frontend/components/WorkspacePanelsLayout/ResizableGutter.tsx` (pointer-driven drag gutter; hover-only visual)
- `platform/investibook/frontend/components/WorkspacePanelsLayout/layoutState.ts` (pure helpers: clamp/load/save)

**Modify**
- `platform/investibook/frontend/app/workspace/[id]/page.tsx` (desktop layout: insert gutters, manage widths/collapse, persist)
- `platform/investibook/frontend/components/SourcesPanel/index.tsx` (support collapsed UI + header toggle icon)
- `platform/investibook/frontend/components/StudioPanel/index.tsx` (support collapsed UI + header toggle icon)

---

## Chunk 1: Layout state helpers (pure + reusable)

### Task 1: Add layout state types + clamp/load/save helpers

**Files:**
- Create: `platform/investibook/frontend/components/WorkspacePanelsLayout/layoutState.ts`

- [ ] **Step 1: Add types + defaults**

```ts
export type DesktopLayoutState = {
  sourcesWidth: number; // expanded width
  studioWidth: number;  // expanded width
  sourcesCollapsed: boolean;
  studioCollapsed: boolean;
};

export const DESKTOP_LAYOUT_DEFAULTS: DesktopLayoutState = {
  sourcesWidth: 280,
  studioWidth: 420,
  sourcesCollapsed: false,
  studioCollapsed: false,
};
```

- [ ] **Step 2: Add clamp function (no DOM dependencies)**

```ts
export type ClampInputs = {
  containerWidth: number;
  gutterWidth: number; // single gutter width (there are 2)
  sourcesMin: number;
  studioMin: number;
  chatMin: number;
  collapsedWidth: number;
};

export function clampDesktopLayout(
  state: DesktopLayoutState,
  inputs: ClampInputs
): DesktopLayoutState {
  const gutterTotal = inputs.gutterWidth * 2;
  const sourcesActualMin = state.sourcesCollapsed ? inputs.collapsedWidth : inputs.sourcesMin;
  const studioActualMin = state.studioCollapsed ? inputs.collapsedWidth : inputs.studioMin;

  const maxSources = Math.max(
    sourcesActualMin,
    inputs.containerWidth - gutterTotal - studioActualMin - inputs.chatMin
  );
  const maxStudio = Math.max(
    studioActualMin,
    inputs.containerWidth - gutterTotal - sourcesActualMin - inputs.chatMin
  );

  const nextSourcesWidth = state.sourcesCollapsed
    ? inputs.collapsedWidth
    : Math.min(Math.max(state.sourcesWidth, inputs.sourcesMin), maxSources);

  const nextStudioWidth = state.studioCollapsed
    ? inputs.collapsedWidth
    : Math.min(Math.max(state.studioWidth, inputs.studioMin), maxStudio);

  // If clamping one side causes chat to violate min, prefer shrinking the side that’s currently larger.
  // (Keep it simple; this is a best-effort clamp.)
  const chatWidth = inputs.containerWidth - gutterTotal - nextSourcesWidth - nextStudioWidth;
  if (chatWidth >= inputs.chatMin) {
    return { ...state, sourcesWidth: nextSourcesWidth, studioWidth: nextStudioWidth };
  }

  const need = inputs.chatMin - chatWidth;
  if (!state.sourcesCollapsed && !state.studioCollapsed) {
    if (nextSourcesWidth >= nextStudioWidth) {
      return {
        ...state,
        sourcesWidth: Math.max(inputs.sourcesMin, nextSourcesWidth - need),
        studioWidth: nextStudioWidth,
      };
    }
    return {
      ...state,
      sourcesWidth: nextSourcesWidth,
      studioWidth: Math.max(inputs.studioMin, nextStudioWidth - need),
    };
  }

  // If one side is collapsed, only shrink the expanded side.
  if (!state.sourcesCollapsed && state.studioCollapsed) {
    return { ...state, sourcesWidth: Math.max(inputs.sourcesMin, nextSourcesWidth - need), studioWidth: nextStudioWidth };
  }
  if (state.sourcesCollapsed && !state.studioCollapsed) {
    return { ...state, sourcesWidth: nextSourcesWidth, studioWidth: Math.max(inputs.studioMin, nextStudioWidth - need) };
  }

  return { ...state, sourcesWidth: nextSourcesWidth, studioWidth: nextStudioWidth };
}
```

- [ ] **Step 3: Add `localStorage` load/save helpers**

```ts
const LAYOUT_KEY_PREFIX = 'investibook.workspace.layout.desktop.v1';

export function getDesktopLayoutStorageKey(workspaceId: string) {
  return `${LAYOUT_KEY_PREFIX}:${workspaceId}`;
}

export function loadDesktopLayout(workspaceId: string): DesktopLayoutState | null {
  try {
    const raw = localStorage.getItem(getDesktopLayoutStorageKey(workspaceId));
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return {
      sourcesWidth: Number(parsed.sourcesWidth),
      studioWidth: Number(parsed.studioWidth),
      sourcesCollapsed: Boolean(parsed.sourcesCollapsed),
      studioCollapsed: Boolean(parsed.studioCollapsed),
    };
  } catch {
    return null;
  }
}

export function saveDesktopLayout(workspaceId: string, state: DesktopLayoutState) {
  try {
    localStorage.setItem(getDesktopLayoutStorageKey(workspaceId), JSON.stringify(state));
  } catch {
    // ignore quota/private mode errors
  }
}
```

- [ ] **Step 4: Quick typecheck sanity via `next build`**

Run:
- [ ] `cd platform/investibook/frontend && pnpm -s build`

Expected:
- Build completes (or fails only for pre-existing issues).

- [ ] **Step 5: Commit**

```bash
git add platform/investibook/frontend/components/WorkspacePanelsLayout/layoutState.ts
git commit -m "feat(investibook): add desktop layout state helpers"
```

---

## Chunk 2: Resizable gutter component (pointer events)

### Task 2: Implement icon-free gutter with hover highlight + drag callbacks

**Files:**
- Create: `platform/investibook/frontend/components/WorkspacePanelsLayout/ResizableGutter.tsx`

- [ ] **Step 1: Implement component API**

```ts
type ResizableGutterProps = {
  ariaLabel: string;
  disabled?: boolean;
  width?: number; // px, default 10
  onDragStart?: () => void;
  onDragDelta: (deltaPx: number) => void;
  onDragEnd?: () => void;
};
```

- [ ] **Step 2: Use pointer events for drag**

Implementation notes:
- On `onPointerDown`, call `e.currentTarget.setPointerCapture(e.pointerId)` and store `startX`.
- On `onPointerMove`, compute `delta = e.clientX - lastX` (or `startX`) and call `onDragDelta(delta)`.
- On `onPointerUp`/`onPointerCancel`, clear drag state and call `onDragEnd`.

- [ ] **Step 3: Styling (hover-only affordance)**

Suggested inline styles:
- Root: `cursor: disabled ? 'default' : 'col-resize'`, `touchAction: 'none'`
- Hover: show a centered 2px line (or subtle background tint) only when `:hover` or dragging

Since we avoid CSS modules, use `onMouseEnter/onMouseLeave` to toggle a local `hover` boolean to drive the highlight style.

- [ ] **Step 4: Quick manual check**

Run:
- [ ] `cd platform/investibook/frontend && pnpm -s dev`

Expected:
- Gutter responds to pointer drag and reports deltas (you can temporarily `console.log` deltas, then remove).

- [ ] **Step 5: Commit**

```bash
git add platform/investibook/frontend/components/WorkspacePanelsLayout/ResizableGutter.tsx
git commit -m "feat(investibook): add resizable gutter component"
```

---

## Chunk 3: Panel header collapse/expand controls + collapsed rendering

### Task 3: SourcesPanel supports collapsed mode + header toggle button

**Files:**
- Modify: `platform/investibook/frontend/components/SourcesPanel/index.tsx`

- [ ] **Step 1: Add new optional props**

```ts
interface SourcesPanelProps {
  isOwner?: boolean;
  collapsed?: boolean;
  onToggleCollapsed?: () => void;
}
```

- [ ] **Step 2: Render header action (ActionIcon)**

Add an `ActionIcon` in the existing header area (top “Sources” label row):
- Tooltip text depends on `collapsed` (Expand/Collapse).
- `aria-label` set.

Use simple chevrons to avoid new icon deps:
- Collapse Sources (expanded): `⟨`
- Expand Sources (collapsed): `⟩`

- [ ] **Step 3: Collapsed rendering**

When `collapsed === true`:
- Keep the top header visible.
- Hide tabs + list content (return early for the body area).
- Optionally abbreviate label to `Src` and rely on Tooltip for full title.
- Ensure no horizontal overflow (avoid large padding).

- [ ] **Step 4: Manual verify**

Expected:
- In collapsed mode, only header shows; clicking expand restores full content.

- [ ] **Step 5: Commit**

```bash
git add platform/investibook/frontend/components/SourcesPanel/index.tsx
git commit -m "feat(investibook): add collapsible Sources panel UI"
```

### Task 4: StudioPanel supports collapsed mode + header toggle button

**Files:**
- Modify: `platform/investibook/frontend/components/StudioPanel/index.tsx`

- [ ] **Step 1: Add new optional props**

```ts
export default function StudioPanel(props: { collapsed?: boolean; onToggleCollapsed?: () => void } = {}) { /* ... */ }
```

- [ ] **Step 2: Add header action (ActionIcon)**

In the existing `Group justify="space-between"` header:
- Add `ActionIcon` on the right side.
- Collapse Studio (expanded): `⟩`
- Expand Studio (collapsed): `⟨`

- [ ] **Step 3: Collapsed rendering**

When `collapsed === true`:
- Keep header visible with “Std” (or “Studio” if it fits).
- Hide the artifacts list/detail body.

- [ ] **Step 4: Commit**

```bash
git add platform/investibook/frontend/components/StudioPanel/index.tsx
git commit -m "feat(investibook): add collapsible Studio panel UI"
```

---

## Chunk 4: Wire it up in workspace page (desktop)

### Task 5: Implement desktop layout state + gutters + persistence

**Files:**
- Modify: `platform/investibook/frontend/app/workspace/[id]/page.tsx`

- [ ] **Step 1: Import helpers + gutter**

```ts
import ResizableGutter from '@/components/WorkspacePanelsLayout/ResizableGutter';
import {
  DESKTOP_LAYOUT_DEFAULTS,
  clampDesktopLayout,
  loadDesktopLayout,
  saveDesktopLayout,
} from '@/components/WorkspacePanelsLayout/layoutState';
```

- [ ] **Step 2: Add constants**

```ts
const COLLAPSED_WIDTH = 64;
const GUTTER_WIDTH = 10;
const SOURCES_MIN = 240;
const STUDIO_MIN = 360;
const CHAT_MIN = 420;
```

- [ ] **Step 3: Measure container width**

Add a `ref` to the desktop row container and measure `clientWidth`:
- Use `ResizeObserver` to update `containerWidth` on resize.
- Keep it simple; no external deps.

- [ ] **Step 4: Initialize state from `localStorage`**

In a `useEffect` (after `workspaceId` is known, and only for desktop):
- `const stored = loadDesktopLayout(workspaceId)`
- Initialize layout state to stored or defaults

- [ ] **Step 5: Apply clamped widths**

Compute a derived `clamped = clampDesktopLayout(state, inputs)` whenever `containerWidth` or `state` changes.

Use clamped widths for:
- Left panel wrapper: `width: clamped.sourcesCollapsed ? COLLAPSED_WIDTH : clamped.sourcesWidth`
- Right panel wrapper: `width: clamped.studioCollapsed ? COLLAPSED_WIDTH : clamped.studioWidth`
- Chat wrapper: `flex: 1`, `minWidth: CHAT_MIN`

- [ ] **Step 6: Insert gutters**

Replace the current `gap: '8px'` layout with explicit gutters:
- `SourcesWrapper`
- `ResizableGutter` (disabled if Sources collapsed)
- `ChatWrapper`
- `ResizableGutter` (disabled if Studio collapsed)
- `StudioWrapper`

Drag behaviors:
- Left gutter drag: `setState(s => ({ ...s, sourcesWidth: s.sourcesWidth + delta }))` (only if not collapsed)
- Right gutter drag: `setState(s => ({ ...s, studioWidth: s.studioWidth - delta }))` (note sign, since moving gutter right increases studio width)

After updating widths, immediately clamp by applying `clampDesktopLayout` (or allow derived clamp to correct next render).

- [ ] **Step 7: Wire collapse buttons**

Pass props:
- `<SourcesPanel collapsed={state.sourcesCollapsed} onToggleCollapsed={...} />`
- `<StudioPanel collapsed={state.studioCollapsed} onToggleCollapsed={...} />`

Toggle behavior:
- On collapse: set `sourcesCollapsed: true` (do not overwrite `sourcesWidth`).
- On expand: set `sourcesCollapsed: false` and keep prior `sourcesWidth` (clamped).

- [ ] **Step 8: Persist on change**

In a `useEffect` watching layout state:
- `saveDesktopLayout(workspaceId, state)`
- Optional: debounce with `setTimeout(250ms)` to reduce churn during drag.

- [ ] **Step 9: Manual verification checklist**

Run:
- [ ] `cd platform/investibook/frontend && pnpm -s dev`

Verify on desktop viewport:
- [ ] Drag left gutter resizes Sources; chat stays >= 420px.
- [ ] Drag right gutter resizes Studio; chat stays >= 420px.
- [ ] Collapse Sources -> width becomes 64px; header shows expand icon.
- [ ] Expand Sources -> restores last expanded width.
- [ ] Same for Studio.
- [ ] Refresh -> state persists.
- [ ] Mobile viewport still uses tabs (no regressions).

- [ ] **Step 10: Build + lint**

Run:
- [ ] `cd platform/investibook/frontend && pnpm -s lint`
- [ ] `cd platform/investibook/frontend && pnpm -s build`

- [ ] **Step 11: Commit**

```bash
git add platform/investibook/frontend/app/workspace/[id]/page.tsx \
  platform/investibook/frontend/components/SourcesPanel/index.tsx \
  platform/investibook/frontend/components/StudioPanel/index.tsx \
  platform/investibook/frontend/components/WorkspacePanelsLayout/ResizableGutter.tsx \
  platform/investibook/frontend/components/WorkspacePanelsLayout/layoutState.ts
git commit -m "feat(investibook): resizable + collapsible workspace side panels"
```

