---
path: /(workspace)/workspace/[[...path]]
surface: workspace
domain: workspace (multi-panel customer workspace)
auth: signed-in
source:
  apps/oshun/web/src/app/(workspace)/workspace/[[...path]]/page.tsx;
  apps/oshun/web/src/components/workspace/MultiPanelWorkspace.tsx
status: walked
last_walked:
  '2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data
  (2xx), console/page-errors, expected content, screenshot verified; live
  screen-reader, touch, offline, and telemetry-delivery checks pending a manual
  AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; content
  re-verified 2026-06-03 against current source; workspace route, tab, search,
  reading, evidence, export, side-panel, and mobile-handoff contracts
  re-verified 2026-06-30 with apps/oshun/web/e2e/workspace-multi-panel.spec.ts
  against real local web + BFF dev infra'
---

# Workspace (multi-panel)

## Purpose

The customer multi-panel workspace: continuity, search, reading, evidence
inspection, study, notes, and assistant handoff in one surface. The route is a
Next.js route-group catch-all (`(workspace)/workspace/[[...path]]`) so any
sub-path is delegated to the `MultiPanelWorkspace` component via `resumePath`.
The component itself is large (`MultiPanelWorkspace`, lines 816-2834 / ~2000
LOC, state-rich).

## Entry points

- **From `/library`** — primary entry; breadcrumb is `Library → Workspace`
- **Deep link with sub-path**: `/workspace/<a>/<b>/<c>` →
  `resumePath = /workspace/a/b/c`; the component resumes the matching tab +
  panel
- **Deep-link query params**:
  `?path=...&tab=...&panel=...&side=...&sideWidth=...` (built via
  `buildWorkspaceDeepLink`)
- **From content surfaces** — "Open in workspace" affordances (verify which
  Veritas/Nisaba surfaces expose this)
- **Iris mobile handoff** — desktop → mobile companion handoff brings the user
  back here when they return to desktop
- **OG/Canonical**: `alternates.canonical = /workspace`

## Layout regions

`page.tsx` mounts `ShellLayout` with `active="library"` and a two-step
breadcrumb (`Library` → `Workspace`). The body is
`<MultiPanelWorkspace resumePath={...} />`.

- **Header**: shell header — Library tab active
- **Breadcrumb band**: `Library` → `Workspace`
- **Main**: `MultiPanelWorkspace` — three composed regions, layout driven by
  `useSemanticViewport()` + `resolveLayout(viewport)`:
  - **Tab bar** — workspace tabs (`workspaceTabs` state, max
    `MAX_WORKSPACE_TABS`); tabs have `id`, `kind`, `activePanel`,
    `activeReadingId`, `activeEvidenceId`, `updatedAt`
  - **Panel grid** — desktop grid shows `PANEL_DEFINITIONS` (all panels); other
    layouts show only the active panel via `getPanelDefinition(activePanel)`
  - **Side panel** — `sidePanel` of `WorkspaceSidePanelId` (default `evidence`);
    `sidePanelWidth` resizable from `DEFAULT_SIDE_PANEL_WIDTH`; resize handle
    drives `resizingSidePanel` state
- **Search**: in-tab search input with `query` state; filters `SEARCH_RESULTS`
  by title/summary/kind/domain/evidenceState
- **Content export**: catalog + plan with format (`IrisContentExportFormat` —
  `markdown` etc.) and flow (`IrisContentExportFlow` — `download`, `embed`, …)
  with gating reasons + preview
- **Iris mobile handoff**: state via `buildWorkspaceIrisMobileHandoffState`;
  write-count exposed via `getWorkspaceIrisMobileHandoffWriteCount`;
  `mobileHandoffSentAt` tracks last handoff

## States

- [x] **Loading** — server-rendered RSC; client hydration brings up tabs;
      `hydrated` state guards SSR-divergent UI
- [x] **Anonymous user** — middleware redirects to sign-in (signed-in required)
- [x] **No resume path** (`/workspace`) — `resumePath` = `/workspace`; default
      tabs from `getDefaultWorkspaceTabs(undefined)`
- [x] **Resume path present** (`/workspace/foo/bar`) — `resumePath` =
      `/workspace/foo/bar`; tab targeting via `normalizeResumePath`
- [x] **Single tab (max-1)** — close-tab guard prevents closing the last tab
      (`workspaceTabs.length <= 1` early-return)
- [x] **MAX tabs reached** — adding a tab evicts the oldest
      (`tabs.length >= MAX_WORKSPACE_TABS ? [...tabs.slice(1), nextTab] : [...tabs, nextTab]`)
- [x] **Empty search query** — empty guidance visible until the user types
- [x] **Non-empty search query** — `filteredSearchResults` filtered by lowercase
      substring across title/summary/kind/domain/evidenceState
- [x] **Desktop layout** (`desktop-grid`) — all panels visible
- [x] **Mobile / narrow layout** — active panel only
- [x] **Side panel resizing** (`resizingSidePanel === true`) — cursor + width
      updates; commits on release
- [ ] **Export busy** (`exportBusy === true`) — disable export button; surface
      `exportRunMessage`
- [x] **Export gate denied** — preview is gate reasons
      (`gate.reasons.     map(entry => entry.detail).join(' ')`) rather than
      rendered artifact
- [x] **Export flow = embed + gate allowed** — `contentExportEmbedPreview`
      populated via `buildIrisContentExportEmbedCode(plan, 'https://oshun.app')`
- [x] **Mobile handoff active** — UI exposes "Open on mobile" with write count
- [x] **Mobile handoff already sent** — `mobileHandoffSentAt` timestamp
      displayed
- [ ] **Offline** — workspace state local; writes queued (verify SW policy for
      handoff writes)
- [ ] **Standalone PWA cold start** — `resumePath` from
      `OSHUN_PWA_RELAUNCH_TARGET_COOKIE` may land here

Verified 2026-06-30 by `workspace-multi-panel.spec.ts`: anonymous deep links
redirect to `/welcome` with the full workspace resume path preserved;
authenticated `/workspace` and `/workspace/notebook/...` hydrate in the library
shell; desktop renders the complete panel inventory; tablet uses the single
active-panel layout; empty and live-BFF-backed search states render; tab
creation, single-tab close affordance removal, max-tab eviction, URL-state
restore, note isolation, side-panel selector/resize, reading anchor selection,
evidence inspection, export allow/deny/download/print/share/embed flows, and
mobile handoff success/failure paths are covered against local web + BFF dev
servers.

## Interactions

### Tab bar

- [x] **Tab click** (`selectWorkspaceTab(tabId)`) — switches active tab
  - Keyboard: Tab/Shift+Tab to focus; Enter activates
  - SR: announces tab name + active state via `aria-selected`
- [x] **"+ New tab" / Add tab** (`addWorkspaceTab(kind)`) — creates a new tab;
      evicts oldest if at MAX
  - Affordances exposed: `reading` and `study`
- [x] **Close tab** (`closeWorkspaceTab(tabId)`) — removes tab if more than one
      is open; guard prevents removing the last
  - Touch ≥ 44×44 px
  - Disabled when last tab

### Panel grid

- [x] **Each panel** rendered per `PANEL_DEFINITIONS` (desktop) or
      `getPanelDefinition(activePanel)` (single-panel)
- [x] **Panel switch** — updates `activePanel` via
      `updateActiveWorkspaceTab({ activePanel })`
- [x] **Reading panel** — live reading anchors populate; `activeReading`
      resolves; switch via `updateActiveWorkspaceTab({ activeReadingId })`
- [x] **Evidence panel** — evidence sources populate; `activeEvidence` resolves;
      switch via `updateActiveWorkspaceTab({ activeEvidenceId })`

### Side panel

- [x] **Side-panel selector** — switches `sidePanel` (`WorkspaceSidePanelId`)
- [x] **Resize handle** — drag updates `sidePanelWidth`; commits on release;
      `resizingSidePanel` state guards the in-progress visual
  - Keyboard: ArrowLeft/ArrowRight nudges width via the separator control
  - SR: separator exposes `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`

### Search input

- [x] **Search input** — controlled, updates `query` state; filter runs through
      `filteredSearchResults` memo
  - Keyboard: native input behavior
  - SR: results count announced (verify `aria-live`)

### Content export pane

- [x] **Export item selector** — chooses from `contentExportCatalog.items`;
      updates `selectedExportItemId`
- [x] **Format selector** — chooses `IrisContentExportFormat` (`markdown`, …);
      updates `selectedExportFormat`
- [x] **Flow selector** — chooses `IrisContentExportFlow` (`download`, `embed`,
      …); updates `selectedExportFlow`; icon swaps to
      `EXPORT_FLOW_ICONS[plan.flow]`
- [x] **Preview pane** — renders `contentExportPreview` (gate reasons OR
      truncated artifact) and `contentExportEmbedPreview` when flow is `embed`
- [x] **Run / Download / Copy** action — runs the chosen flow and stores a
      receipt / download / print / share / embed result; transient `exportBusy`
      and `exportRunMessage` remain narrow visual-state assertions; respects
      `gate.allowed`
- [x] **Embed code** — copy-to-clipboard via
      `buildIrisContentExportEmbedCode(plan, 'https://oshun.app')`

### Iris mobile handoff

- [x] **"Open on mobile" / Handoff** — sends current state to mobile companion
      via `irisMobileHandoffState`; updates `mobileHandoffSentAt`
- [x] **Handoff write count badge** —
      `getWorkspaceIrisMobileHandoffWriteCount(state)`; updates reactively
- [ ] Cross-reference [`shell/03-pwa-behavior.md`](../shell/03-pwa-behavior.md)
      for multi-device continuity

### Deep link sharing

- [x] **Copy deep link** —
      `buildWorkspaceDeepLink({ path, tabId, panelId, sidePanel, sideWidth })`;
      shareable URL preserves state

## Data & contracts

- **Reads**: `READING_ANCHORS`, `EVIDENCE_SOURCES`, `SEARCH_RESULTS`,
  `DEFAULT_WORKSPACE_TABS`, `PANEL_DEFINITIONS` — fixtures in the component file
  today; live BFF wiring TBD
- **Writes**: Iris content-export plan execution; mobile-handoff writes (Iris
  substrate); tab persistence (verify whether tabs persist to BFF or
  localStorage)
- **Realtime**: mobile-handoff status (one-way; from this surface)
- **Caching**: server-rendered; client hydration uses fixtures today; PWA
  relaunch target may land here
- **Auth/role check**: middleware enforces signed-in; no special role

## Cross-references

- Shell: [`shell/01-app-shell.md`](../shell/01-app-shell.md),
  [`shell/03-pwa-behavior.md`](../shell/03-pwa-behavior.md),
  [`shell/06-keyboard-a11y.md`](../shell/06-keyboard-a11y.md)
- Sibling workspace route:
  [`app-surface-catchall.md`](./app-surface-catchall.md)
- Library:
  [`../customer/02-home-discovery/library.md`](../customer/02-home-discovery/library.md)
  — primary entry surface (active tab is `library`)
- Component sources:
  - `apps/oshun/web/src/components/workspace/MultiPanelWorkspace.tsx` (line 816:
    `MultiPanelWorkspace`)
- Feature spec: [`V1/features.md`](../../V1/features.md#iris-memory-and-identity) (multi-device
  continuity)
- Architecture:
  [`V1/ARCHITECTURE.md`](../../V1/ARCHITECTURE.md#customer-web--appsoshunweb)

## Open questions / known gaps

- [x] Walk the full `MultiPanelWorkspace` (~2000 LOC, lines 816-2834) to
      enumerate every panel kind, tab kind, side-panel kind, and export
      format/flow — verified from current source and E2E selectors: panels
      `continuity/search/reading/evidence/study/notes/assistant/export`, tab
      kinds `reading/study`, side panels `continuity/evidence/notes/assistant`,
      formats `pdf/markdown/json`, flows `download/print/share_card/embed`
- [ ] Document fixtures (`READING_ANCHORS`, `EVIDENCE_SOURCES`,
      `SEARCH_RESULTS`) and the BFF endpoints they will be replaced with
- [ ] Confirm tab persistence policy (localStorage, BFF, neither)
- [ ] Document side-panel resize keyboard contract (arrow keys / Home / End)
- [ ] Snapshot mobile-handoff write payload shape
      (`buildWorkspaceIrisMobileHandoffState` output)
- [ ] Confirm whether `/(workspace)/workspace/[[...path]]` (this route) and any
      planned tenant-scoped workspace share a layout (`(workspace)` is a Next.js
      route group — tenant workspace may live alongside)
