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 isLibrary → 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 viabuildWorkspaceDeepLink) - 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 byuseSemanticViewport()+resolveLayout(viewport):- Tab bar — workspace tabs (
workspaceTabsstate, maxMAX_WORKSPACE_TABS); tabs haveid,kind,activePanel,activeReadingId,activeEvidenceId,updatedAt - Panel grid — desktop grid shows
PANEL_DEFINITIONS(all panels); other layouts show only the active panel viagetPanelDefinition(activePanel) - Side panel —
sidePanelofWorkspaceSidePanelId(defaultevidence);sidePanelWidthresizable fromDEFAULT_SIDE_PANEL_WIDTH; resize handle drivesresizingSidePanelstate
- Tab bar — workspace tabs (
- Search: in-tab search input with
querystate; filtersSEARCH_RESULTSby title/summary/kind/domain/evidenceState - Content export: catalog + plan with format (
IrisContentExportFormat—markdownetc.) and flow (IrisContentExportFlow—download,embed, …) with gating reasons + preview - Iris mobile handoff: state via
buildWorkspaceIrisMobileHandoffState; write-count exposed viagetWorkspaceIrisMobileHandoffWriteCount;mobileHandoffSentAttracks last handoff
States#
- Loading — server-rendered RSC; client hydration brings up tabs;
hydratedstate guards SSR-divergent UI - Anonymous user — middleware redirects to sign-in (signed-in required)
- No resume path (
/workspace) —resumePath=/workspace; default tabs fromgetDefaultWorkspaceTabs(undefined) - Resume path present (
/workspace/foo/bar) —resumePath=/workspace/foo/bar; tab targeting vianormalizeResumePath - Single tab (max-1) — close-tab guard prevents closing the last tab
(
workspaceTabs.length <= 1early-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 query —
filteredSearchResultsfiltered 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; surfaceexportRunMessage - Export gate denied — preview is gate reasons
(
gate.reasons. map(entry => entry.detail).join(' ')) rather than rendered artifact - Export flow = embed + gate allowed —
contentExportEmbedPreviewpopulated viabuildIrisContentExportEmbedCode(plan, 'https://oshun.app') - Mobile handoff active — UI exposes "Open on mobile" with write count
- Mobile handoff already sent —
mobileHandoffSentAttimestamp displayed - Offline — workspace state local; writes queued (verify SW policy for handoff writes)
- Standalone PWA cold start —
resumePathfromOSHUN_PWA_RELAUNCH_TARGET_COOKIEmay 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:
readingandstudy
- Affordances exposed:
- 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) orgetPanelDefinition(activePanel)(single-panel) - Panel switch — updates
activePanelviaupdateActiveWorkspaceTab({ activePanel }) - Reading panel — live reading anchors populate;
activeReadingresolves; switch viaupdateActiveWorkspaceTab({ activeReadingId }) - Evidence panel — evidence sources populate;
activeEvidenceresolves; switch viaupdateActiveWorkspaceTab({ activeEvidenceId })
Side panel#
- Side-panel selector — switches
sidePanel(WorkspaceSidePanelId) - Resize handle — drag updates
sidePanelWidth; commits on release;resizingSidePanelstate guards the in-progress visual- Keyboard: ArrowLeft/ArrowRight nudges width via the separator control
- SR: separator exposes
aria-valuemin,aria-valuemax, andaria-valuenow
Search input#
- Search input — controlled, updates
querystate; filter runs throughfilteredSearchResultsmemo- Keyboard: native input behavior
- SR: results count announced (verify
aria-live)
Content export pane#
- Export item selector — chooses from
contentExportCatalog.items; updatesselectedExportItemId - Format selector — chooses
IrisContentExportFormat(markdown, …); updatesselectedExportFormat - Flow selector — chooses
IrisContentExportFlow(download,embed, …); updatesselectedExportFlow; icon swaps toEXPORT_FLOW_ICONS[plan.flow] - Preview pane — renders
contentExportPreview(gate reasons OR truncated artifact) andcontentExportEmbedPreviewwhen flow isembed - Run / Download / Copy action — runs the chosen flow and stores a
receipt / download / print / share / embed result; transient
exportBusyandexportRunMessageremain narrow visual-state assertions; respectsgate.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; updatesmobileHandoffSentAt - Handoff write count badge —
getWorkspaceIrisMobileHandoffWriteCount(state); updates reactively - Cross-reference
shell/03-pwa-behavior.mdfor multi-device continuity
Deep link sharing#
- 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/03-pwa-behavior.md,shell/06-keyboard-a11y.md - Sibling workspace route:
app-surface-catchall.md - Library:
../customer/02-home-discovery/library.md— primary entry surface (active tab islibrary) - Component sources:
apps/oshun/web/src/components/workspace/MultiPanelWorkspace.tsx(line 816:MultiPanelWorkspace)
- Feature spec:
V1/features.md(multi-device continuity) - Architecture:
V1/ARCHITECTURE.md
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: panelscontinuity/search/reading/evidence/study/notes/assistant/export, tab kindsreading/study, side panelscontinuity/evidence/notes/assistant, formatspdf/markdown/json, flowsdownload/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
(
buildWorkspaceIrisMobileHandoffStateoutput) - 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)