Workspace v2 needed tabs that behave more like browser tabs than a UI row. Users keep several business objects open, refresh and shared links still work, and hidden runtimes do not get to steal the URL, overlays, events, or foreground CPU. Most of the work was ownership: who owns intent, URL, tab state, runtime cache, sandbox effects, and the rendered frame.

Figure 0: A generic workbench using browser-like tabs. Users keep several work items open, switch back without reload, and only the focused tab owns URL, overlays, events, and foreground CPU. generated by gpt-image-2.
Background and goals
Workspace v2 changed the workbench from one page with one active context into a place where several workstreams, subapp views, and ticket objects can stay open at the same time. The user expectation is simple and unforgiving:
- several tasks stay open,
- switching back keeps state,
- refresh and shared links still land on the right business page,
- and a modal opened by one sub-application cannot cover another tab.
Business goals:
| Goal | User Experience |
|---|---|
| Multi-tasking | Users can keep multiple workstreams, subapps, and tickets open without repeatedly returning to the home page. |
| Context retention | Filters, scroll position, iframe state, and inner Workstream views should survive common tab switches. |
| Reliable links | Refresh, copied links, and external deep links should recover to a reasonable tab and business page. |
| Subapp integration without tab internals | Sub-applications express intent such as “open this page”; they do not need to understand the host tab implementation. |
| Browser-like responsiveness | Switching should be fast, and background tabs should not steal foreground CPU. |
Technical goals:
| User Expectation | Engineering Requirement | Failure If Missing |
|---|---|---|
| Multiple workstreams can stay open | Persist opened tabs, order, and pinned state | Refresh loses tabs, or different browser windows show different tab lists. |
| Switching back keeps state | Keep a bounded number of DOM / iframe runtimes warm | Every switch reloads the page; filters, scroll, and iframe state are lost. |
| URLs remain refreshable and shareable | Recover the target tab from the browser URL; write the business URL when a tab is activated | Copied links open as orphan pages, or the address bar points to the wrong tab. |
| Multiple windows work together | Synchronize tab-list mutations across windows | One window closes a tab while another still shows stale state. |
| Subapps can open pages | SDKs and event buses express intent; the host decides how to open | Tab behavior becomes scattered across subapps and bypasses capacity/reuse rules. |
| Hidden tabs do not affect the current tab | Scope history, DOM, overlays, events, and focus by tab | A hidden iframe changes the current URL, shows an overlay on another tab, or starts background work. |
| Switching feels responsive | Measure and schedule first load, hot switch, and background work separately | The page is visible but not clickable, while old metrics report a short duration. |

Figure A1: Product goals mapped to engineering constraints. The diagram breaks browser-like tabs into concrete system requirements and failure modes. generated by gpt-image-2.
The final design principle is:
Keep the address bar as a real business URL; use server state to record which tabs are open; keep only a bounded working set warm; and put host-owned boundaries around subapp history, DOM, overlays, and events.
The system has two core questions:
- Should two entries reuse the same tab? For example, the same workstream should reuse one tab, while its internal view is preserved as URL/subPath state.
- Which tab owns runtime side effects now? Only the focused tab can write the browser URL, show overlays, receive foreground events, and consume foreground CPU.
Architecture layers

Figure A2: Final layered architecture. Isolation and Observability are intentionally separate: Isolation prevents hidden runtimes from changing the current tab; Observability proves where latency, blocked writes, or regressions happen. generated by gpt-image-2.
| Layer | Problem | Core Mechanism |
|---|---|---|
| Intent Interface | The same business object should not duplicate tabs or behave differently when opened from a menu, subapp button, SDK, iframe, or URL. | Normalize every entry into an open intent; the host decides new browser tab, absorb current tab, focus existing tab, or create a new tab. |
| URL And Tab Ownership | Refresh or shared links should recover to the same business page, not an internal orphan tab route. | Keep the address bar as a business URL; parse it into a tab input and match it against opened tabs. |
| Persistent Tab State | Refresh should not lose tabs; two browser windows should not split into different tab lists. | BFF stores opened tabs; React Query gives instant local UI; BroadcastChannel invalidates other windows after mutation. |
| Runtime Cache | Recent tabs should switch back quickly, but many opened tabs must not create unbounded memory or CPU pressure. | Separate opened tabs, hot runtime pool, and Workstream view cache. Evicting a runtime does not delete the tab. |
| Isolation Boundary | After switching to tab B, tab A must not change the URL, show overlays on B, receive foreground events, or start foreground work. | Scope history, window.parent, document/body, overlays, and focus events by current owner. |
| Rendered Runtimes | Users should see and interact with only the current tab. Clicks, overlays, and URL writes must belong to that tab. | Put every hot runtime into a stable frame; only the focused owner is visible, clickable, and allowed to receive foreground events. |
| Observability | When first load is slow, switching is janky, an overlay crosses tabs, or the URL is wrong, we need to know which layer failed. | FMP for first load, tab switch v3 for switching, long task/frame gap for visible jank, scope-drop logs for blocked writes, stress gates for regressions. |
Layer 1: Intent Interface
Problem
Users can open the same business object from many entry points: sidebar, tab row, a subapp button, MF event bus, iframe postMessage, or a copied business URL. The expected result is consistent: an already-open object is focused, a new object opens once, and a view inside the same subapp can often navigate within the current tab.
If each entry makes its own decision, users see direct failures: a menu click reuses a tab but a subapp button creates a duplicate; a shared link restores the page but SDK navigation loses the last inner view; one entry respects tab capacity while another bypasses it.
Solution
Subapps only express intent. The host decides how to execute it.
| Source | Input | Host Decision |
|---|---|---|
| User tab click | tab row id | Restore saved tab URL, activate runtime, write browser URL. |
| MF event bus | TAB_OPEN_REQUEST, NAVIGATE_TO_URL |
Focus existing tab, absorb into current tab, create a tab, or open a new browser window. |
| Seto iframe | window.postMessage envelope |
Validate origin and payload, then convert to a host event bus request. |
| Subapp SDK | Passing Event Bus to Subapp through window object.Subapps have their own: openWorkstreamTab, openSubappViewTab, openSubApp |
Normalize payload, find existing tab, create if needed. |
Simplified pseudocode:
1 | // A subapp sends intent. It does not mutate host state. |
Layer 2: URL And Tab Ownership
Problem
The browser has one address bar, while the workspace can keep multiple tab runtimes alive. We cannot replace business URLs with internal routes such as /tabs/:id.
| URL Shape | What Happens When Users Share It |
|---|---|
/tabs/abc123 |
It only means “my local tab list has id=abc123.” Another user or another window does not know which workstream, ticket, or view it represents. |
/workspace/workstream/123/schedule/456 |
The URL contains the business object. Refresh, bookmarks, IM sharing, and external deep links can recover the same business page. |
So the address bar stays as a real business URL:
/workspace/workstream/:id/.../workspace/scheduling/schedule/view/:viewId/workspace/audit_workbench/ticket/custom_view/:viewId
Internally, the host extracts business fields from the URL and uses them to decide whether two entries should reuse the same tab.
| Tab Type | Fields Used For Identity | Meaning |
|---|---|---|
| Workstream | workstreamId |
Inner views are stored as path/subPath under one Workstream tab. |
| SubApp root | subAppType |
The subapp root is a stable tab. |
| SubApp view | viewType + viewId |
A concrete business view can become its own tab or be absorbed into the current subapp tab. |
| Ticket | ticketId + viewType |
Ticket objects are suitable independent tabs. |
| Non-tab route | None | Home, notification, redirect, and unknown routes do not enter tab lifecycle. |

Figure A3: URL and tab synchronization. The address bar remains a business URL; the host maps it to an internal tab owner. generated by gpt-image-2.
Browser URL -> Tab
This path handles refresh, copied links, and external deep links.
1 | function onRouteChanged(location) { |
Tab -> Browser URL
This path handles tab clicks.
1 | function activateTab(tab) { |
Window -> Window
Tab list mutations are persistent facts, not local React state.
1 | async function mutateTabs(mutation) { |
Layer 3: Persistent Tab State
Problem
Users see three concrete failures if tab state is only local: refresh loses the tab row, add/remove/pin feels delayed if every mutation waits for the server, and two browser windows drift apart after one window mutates the tab list.
Backend told me the Tab actions and list would be relatively slow because it involved a lot of services.
Solution
Persistent state is handled by BFF plus React Query.

Figure A3.5: Persistent tab state. React Query makes the current window fast; BFF stores the final fact; BroadcastChannel tells other windows to invalidate and refetch. generated by gpt-image-2.
| Module | Responsibility |
|---|---|
| BFF tab controller | list/add/remove/pin/unpin/reorder, plus merging opened tabs and pinned tabs. |
| React Query | Single tab-list cache key, stale-time policy, focus refetch. |
| Optimistic mutation | Insert a temporary tab before server response; replace it when BFF returns the final tab. The temporary tab will be locked from actions like pin/unpin/delete before we can get its real id. |
| BroadcastChannel | After mutation succeeds, tell other windows to invalidate and refetch. |
Simplified flow:
1 | function useAddTab() { |
Layer 4: Runtime Cache
Problem
Users expect recently used tabs to switch back quickly, with scroll, form, and iframe state intact. But if every opened tab keeps a live runtime, the current tab slows down and memory grows without a bound.
The key is to separate three concepts that look similar in UI but have different lifecycles.

Figure A4: Three cache layers. Opened tabs are durable user intent; hot runtime pool is bounded live resource; scoped view cache keeps inner Workstream views. Idle prewarm prepares likely future switches after first screen; it is not unlimited background loading. generated by gpt-image-2.
| Layer | Question It Answers | Lifecycle |
|---|---|---|
| Opened tabs | Which tabs should appear in the tab row? | Persisted by BFF. Evicting runtime does not delete the tab. |
| Hot runtime pool | Which runtimes are alive now? | Bounded LRU/working set. Hidden runtimes are warm but not foreground. |
| Scoped view cache | Can an inner Workstream view return quickly? | Cached by Workstream scopeKey; capped inner views. |
Hot-pool update:
1 | function onTabActivated(tabId) { |
Idle prewarm:
1 | afterFirstScreenReady(() => { |
Layer 5: Isolation Boundary
Problem
After the user switches to tab B, a subapp inside tab A can still be alive in the background. If we only hide the DOM, users can still see these failures:
| Problem | Cause |
|---|---|
| The current address bar suddenly changes to another tab’s URL. | A hidden iframe can still call history.pushState / replaceState. |
| Browser back wakes up a route inside a non-current tab. | Multiple iframes can observe the same popstate / hashchange. |
| A modal or toast from tab A covers tab B; dropdown positioning drifts. | Component libraries append overlays to the global document.body. |
| A background tab thinks it is focused and starts fetching or running heavy work. | Lifecycle events are broadcast globally instead of filtered by tab id. |

Figure A5: Seto integration and tab isolation. Seto loads the HTMLSandbox; the Workspace host adds tab ownership around Seto lifecycle and sandbox window boundaries. generated by gpt-image-2.
Seto Integration
| Category | Problem | Cause / Seto Constraint | Boundary Needed |
|---|---|---|---|
| Hidden tab updates the visible browser URL. | Seto runtimes can still call window.history or Seto RAW_HISTORY while kept alive in the warm pool. |
Add a host tab-ownership context around Seto runtime. | |
A subapp bypasses the sandbox through window.parent. |
Some subapps or SDKs call window.parent.history, window.parent.document. |
Return a tab-scoped parent proxy: parent history delegates to scoped history, parent document resolves to the tab document scope. | |
| The first sandbox route write is missed, or early patching breaks sandbox startup. | The raw sandbox window is only reliable after Seto reports ready; patching too late misses the first route replace. | Register the runtime frame in onSandboxReady(), then reset the initial URL and install tab-scoped patches. | |
| Switching back shows another tab’s content, scroll state, or mounted DOM. | If Seto content mounts into one global container, multiple hot tabs share the same DOM owner. | getContainer() must return the current HotTabFrame root, so each tab owns a stable DOM subtree. |
|
| Modal or toast from hidden tab appears over the active tab. | Component libraries append Modal/Dropdown/Toast to global document.body. |
Route body append and portal operations to a tab-owned overlay root. | |
| Dropdown or tooltip is contained but positioned incorrectly. | Floating overlays depend on trigger coordinates and viewport context; simply moving them into a modal root can break placement. | Separate content overlays from floating overlays; keep floating placement tied to the trigger’s tab coordinate system. | |
| Background runtime consumes CPU during active tab switch. | Hidden runtimes can continue timers, lifecycle prime, prewarm, or refresh work while the foreground tab is settling. | Use a foreground lease and background scheduler; defer background work until the active tab is stable. | |
| Hidden tab sends event to event bus and opens the wrong tab through SDK calls. | SDK and MF event bus calls express user intent, but the host must decide which tab owns that intent. | Normalize SDK / postMessage / MF event bus calls into tab-scoped host commands. |
Seto capabilities used:
| Seto Capability | Requirement | Integration |
|---|---|---|
HTMLSandbox |
Reuse Seto loading, entry, basename, and lifecycle. | The host wraps tab owner around Seto runtime; it does not rebuild the runtime. |
getContainer() |
Mount DOM into the current tab, not the global page. | Return the root inside HotTabFrame; re-register DOM scope when root changes. |
onSandboxReady(sandbox) |
Access a patchable sandbox window. | Use sandbox.raw.win to register runtime frame and patch history/parent/event. |
BaseSandbox |
Know which sandbox is calling document/body APIs. | Use WeakMap to associate sandbox with its tab root. |
DocExternals / document plugin context |
Scope document.body, queries, and append operations. |
Restrict queries to scoped root; route body portals to tab overlay root. |
sandbox.raw.win.RAW_HISTORY |
Patch the history Seto actually uses. | Validate pushState / replaceState target before syncing host history. |
Integration order:
1 | function SetoTabRuntime({ tabId, entry, initialUrl }) { |
History / Window Scope
Seto architecture has a lot of history:
| History | Belongs to | Usage | Risk |
|---|---|---|---|
| window.history | Workspace main app | It will change the url directly | NA |
| iframeWin.history | Seto sandbox sub app history | Sub app code call window.history |
By default it doesn’t know whether workspace tab is active or not |
| iframeWin.RAW_HISTORY | Seto history plugin maintained history | Seto use it to simulate/sync sandbox internal router | If we don’t inspect, Seto internal replace/push will ignore tab status |
| scopedHistory | The history that we created for scoping tab’s history | It will check whether tab is activated and then decide whether raw history / host history | The isolation layer we added |
| window.parent.history | Subapp can escape and visit parent history by this | Some of the subapps call parent history directly | tab can write URL directlywindow.parent.history.pushState(...)window.parent.location.href = ... |
1 | function scopedPushState(state, unused, url) { |
window.parent is also a Proxy, not the raw host window:
1 | parentProxy.get('history') -> scopedHistory |
Subapps still use the same interface shape, but every capability they receive is already scoped by tab.
Event Scope
Only receive event from active tab.
1 | function publishTabLifecycle(type, targetTabId) { |
DOM / Overlay Scope
The user-facing issue is simple: a popup or dropdown looks like UI of the current tab, but many libraries append it to global document.body. If the host does not intercept that, hidden tab overlays can cover the current tab or dropdowns can drift because their coordinate system changed.
Overlay types are different:
- Select, Dropdown, and Tooltip are floating overlays and need trigger-relative positioning.
- Modal, Drawer, Toast, and Notification are content overlays and should be constrained to the tab content area.
- Large-overlay geometry heuristics should only apply to
position: fixed, otherwise absolute dropdowns drift.
Step 1: make document APIs resolve inside the tab
When code inside the sandbox calls:
1 | document.body |
we do not let it see the host’s global document by default. We resolve the current sandbox first, find its registered tab root, and answer from that root.
1 | docUse('body', ctx => { |
Step 2: create two overlay roots for each tab
For every registered tab root, we create:
1 | HotTabFrame(tab A) |
| Root | Used for |
|---|---|
| workspace-subapp-overlay-root | floating overlays: dropdown, tooltip, popover, listbox |
| workspace-subapp-content-overlay-root | modal-like content: dialog, drawer, toast, large blocking overlays |
This split matters. A Modal should be contained with the tab, but a Dropdown often depends on trigger coordinates. If we force every overlay into the same content root, dropdowns can drift.
Step 3: intercept append and route the node
When a library does something like:
1 | document.body.appendChild(node); |
or Seto runtime inserts body children into the scoped root, we classify the node before keeping it there.
1 | function routeRuntimeBodyNode(node, tabRoot) { |
Step 4: hide overlay roots that do not belong to the focused tab
Warm pool keeps hidden tabs mounted, so their overlay roots may still exist. On focus change, Workspace updates the visible overlay owner:
1 | useLayoutEffect(() => { |
Then overlay roots are toggled by tab id:
1 | function updateSubappOverlayFocus(root) { |
Foreground Leasing
We must protect and prioritize activated tab tasks and postpone other background tabs tasks so that the performance during switching won’t be laggy.
1 | // trigger: when user focus on a tab |
Layer 6: Rendered Runtimes
Problem
The user sees one current tab, but the host may keep native Workstream, Seto iframe, and MF subapp runtimes alive. If these runtimes are treated as ordinary React components, users see direct failures:
| Problem | Cause |
|---|---|
| After switching from tab A to B, A’s DOM or iframe still blocks the page or receives clicks. | Hot runtime is kept alive but not removed from the interactive layer. |
| B is visible, but a click, overlay, or URL write still applies to A. | Visual focus and runtime owner are not synchronized. |
| A background tab re-renders because the current URL changed. | Every retained runtime reads the same global location instead of per-tab location. |
| Some pages keep state while others remount. | Native routes, Seto sandbox, and MF iframe have different lifecycle models. |
| Metrics say “switch complete”, but the visible page cannot be clicked. | Mounted, visible, and interactive are different phases. |
Solution
Every hot tab gets a stable frame. The frame is not decoration; it normalizes different runtimes into host semantics.
| Step | What is set | Where it is used |
|---|---|---|
| Seto runtime focus | focusedRuntimeTabId | Scoped history checks whether a sandbox history write may update the host URL. |
| Overlay focus | focusedWorkspaceOverlayTabId | Tab-owned overlay roots are shown or hidden by data-workspace-overlay-tab-id. |
| Warm runtime | WarmPool entry and LRU timestamp | WorkspaceContentHost renders or reuses the matching HotTabFrame. |
| Lifecycle event | TAB_BLURRED / TAB_FOCUSED with tabId | Subapps, MF components, and SDK listeners filter lifecycle events by tab id. |
| Switch metric | visible timestamp for nextTabId | Tab switch metrics distinguish shell activation from real frame visibility. |
Switching becomes an ownership update:
1 | function commitFocusedTab(nextTabId) { |
Key tradeoffs
| Tradeoff | Why Not The Simpler Option | Final Choice |
|---|---|---|
| URL | Internal routes such as /tabs/:id are simple, but the link only means something inside one user’s local tab session. |
Keep real business URLs and let the host resolve them into tab ownership. |
| Cache | Keeping every runtime alive makes switching fast, but memory and CPU grow without a bound. | Separate opened tabs from hot runtimes; opened tabs are durable, hot runtime pool is capped. |
| Seto | Reloading on every switch is clean, but it loses state and makes switching slow. | Keep Seto runtime warm, then scope host-facing APIs. |
| Interface | Letting every subapp understand tabs reduces host code at first, but spreads coupling everywhere. | Subapps only send intent; the host owns reuse, absorb, focus, creation, and capacity. |
| Observability | A single duration metric is easy, but it hides post-visible jank and cross-tab side effects. | Split first load, hot switch, long task, scope drop, and stress gates. |
The tab row is the easy part. The harder part is the ownership model: which runtime may write the URL, which tab owns DOM and overlays, which listeners receive foreground events, and which work may use foreground CPU. Once those rules are explicit, the workspace starts behaving like tabs instead of hidden pages stepping on each other.