V1 Web PWA · Surface walkthrough

Workspace (multi-panel)

A per-surface walkthrough of the V1 Web PWA workspace surface: layout, states, interactions, data, and cross-references.

walked
8sections6 minread

On this page

Context. surface workspace · domain workspace (multi-panel customer workspace) · route /(workspace)/workspace/[[...path]] · auth signed-in · source apps/oshun/web/src/app/(workspace)/workspace/[[...path]]/page.tsx; apps/oshun/web/src/components/workspace/MultiPanelWorkspace.tsx

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

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 (LibraryWorkspace). The body is <MultiPanelWorkspace resumePath={...} />.

  • Header: shell header — Library tab active
  • Breadcrumb band: LibraryWorkspace
  • 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 panelsidePanel 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 (IrisContentExportFormatmarkdown etc.) and flow (IrisContentExportFlowdownload, embed, …) with gating reasons + preview
  • Iris mobile handoff: state via buildWorkspaceIrisMobileHandoffState; write-count exposed via getWorkspaceIrisMobileHandoffWriteCount; mobileHandoffSentAt tracks last handoff

States#

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

  • Tab click (selectWorkspaceTab(tabId)) — switches active tab
    • Keyboard: Tab/Shift+Tab to focus; Enter activates
    • SR: announces tab name + active state via aria-selected
  • "+ New tab" / Add tab (addWorkspaceTab(kind)) — creates a new tab; evicts oldest if at MAX
    • Affordances exposed: reading and study
  • 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#

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

Side panel#

  • Side-panel selector — switches sidePanel (WorkspaceSidePanelId)
  • 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#

  • 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#

  • Export item selector — chooses from contentExportCatalog.items; updates selectedExportItemId
  • Format selector — chooses IrisContentExportFormat (markdown, …); updates selectedExportFormat
  • Flow selector — chooses IrisContentExportFlow (download, embed, …); updates selectedExportFlow; icon swaps to EXPORT_FLOW_ICONS[plan.flow]
  • Preview pane — renders contentExportPreview (gate reasons OR truncated artifact) and contentExportEmbedPreview when flow is embed
  • 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
  • Embed code — copy-to-clipboard via buildIrisContentExportEmbedCode(plan, 'https://oshun.app')

Iris mobile handoff#

  • "Open on mobile" / Handoff — sends current state to mobile companion via irisMobileHandoffState; updates mobileHandoffSentAt
  • Handoff write count badgegetWorkspaceIrisMobileHandoffWriteCount(state); updates reactively
  • Cross-reference shell/03-pwa-behavior.md for multi-device continuity
  • Copy deep linkbuildWorkspaceDeepLink({ 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#

Open questions / known gaps#

  • 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)