diff --git a/docs/en/wework/developer-guide/wework-chat-state-sources.md b/docs/en/wework/developer-guide/wework-chat-state-sources.md
index d039ba0f04..3958691c3d 100644
--- a/docs/en/wework/developer-guide/wework-chat-state-sources.md
+++ b/docs/en/wework/developer-guide/wework-chat-state-sources.md
@@ -301,6 +301,15 @@ The workbench owns live state that cannot be serialized reliably, including comp
Do not unmount the workbench during route transitions, and do not add incomplete restoration fallbacks for Terminal or browser state. New top-level pages should join the auxiliary-page rendering branch without changing the workbench lifecycle.
+Multiple top-level document tabs use React `Activity` to retain independent
+workbench instances. Updates inside a hidden `Activity` may be deferred while
+portals that it created in global titlebar targets remain attached. Every
+global titlebar portal must therefore identify its owning document tab, and
+`AppRoutes` must control portal visibility from the active-tab state. Do not
+rely only on conditional rendering inside the hidden workbench to withdraw a
+portal. After a tab switch, only the active tab may expose its main header,
+panel actions, right-workspace title, and feedback entry.
+
## Workbench Pane Cache
The desktop workbench caches up to 20 regular panes so messages, composer drafts, and local UI state survive switches between parallel tasks. Once the limit is exceeded, inactive panes are evicted in least-recently-used order. Panes for running tasks and panes with pinned terminals remain mounted outside the regular cache limit until the task finishes or the terminal is unpinned. Maintain this boundary through the existing `CachedWorkbenchPaneStack` LRU and pinning mechanisms; do not add a second pane cache in the layout.
diff --git a/docs/zh/wework/developer-guide/wework-chat-state-sources.md b/docs/zh/wework/developer-guide/wework-chat-state-sources.md
index 0212a63cd0..325c49eae9 100644
--- a/docs/zh/wework/developer-guide/wework-chat-state-sources.md
+++ b/docs/zh/wework/developer-guide/wework-chat-state-sources.md
@@ -264,6 +264,12 @@ Wework 的聊天 UI 不能把持续输出的完整正文长期保存在 React st
不要通过路由切换卸载工作台,也不要为 Terminal 或浏览器增加不完整的状态恢复 fallback。新增顶层页面时,应将它纳入辅助页面渲染分支,并保持工作台生命周期不变。
+多个顶层文档标签会用 React `Activity` 保持各自的工作台实例。隐藏
+`Activity` 的更新可能延迟提交,但它创建到全局标题栏的 Portal 仍会保留在目标节点中。
+因此所有全局标题栏 Portal 都必须标记所属文档标签,并由 `AppRoutes` 的活动标签状态统一
+控制可见性;不能仅依赖隐藏工作台内部的条件渲染来撤销 Portal。切换标签时只能显示当前
+活动标签的主标题栏、面板操作区、右侧工作区标题和反馈入口。
+
## 工作台 pane 缓存
桌面工作台最多缓存 20 个普通 pane,使用户在并行任务之间切换时保留消息、输入草稿和局部 UI 状态。超出上限后按最近使用顺序淘汰非活跃 pane;正在运行的任务和已固定终端的 pane 不计入普通缓存上限,并保持挂载直到任务结束或终端解除固定。维护此边界时应继续复用 `CachedWorkbenchPaneStack` 的 LRU 与固定机制,不能在布局层增加第二套 pane 缓存。
diff --git a/wework/src/App.tsx b/wework/src/App.tsx
index ba8837e2b4..bbb4663230 100644
--- a/wework/src/App.tsx
+++ b/wework/src/App.tsx
@@ -2,6 +2,7 @@ import {
Activity,
useCallback,
useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -86,6 +87,8 @@ import { WorkspaceTabsProvider } from '@/features/workspace-tabs/WorkspaceTabsCo
import { useOptionalWorkspaceTabs } from '@/features/workspace-tabs/workspaceTabsContextValue'
import type { WorkspaceTab } from '@/features/workspace-tabs/workspaceTabs'
import type { User } from '@/types/api'
+import { WorkspaceTabPortalOwner } from '@/components/topnav/TitlebarActionsPortal'
+import { setActiveWorkspaceTabPortalOwner } from '@/components/topnav/workspaceTabPortalOwnership'
const WORKBENCH_STARTUP_REVEAL_TIMEOUT_MS = 6000
const POPOUT_WINDOW_LABEL = 'popout-window'
@@ -226,54 +229,56 @@ function WorkspaceTabSurface({
const usesAuxiliaryDesktopSurface = auxiliaryActive && isTauriRuntime()
return (
-
-
- {renderProvider ? (
-
- {onOpenWeworkForAppshot && active && !iframe ? (
-
- ) : null}
- {renderWorkbench ? (
-
-
-
- ) : null}
- {auxiliaryPage ? (
-
- {auxiliaryPage}
-
- ) : null}
-
- ) : null}
- {renderedIframe ? (
-
-
+
+
+ {renderProvider ? (
+
-
- ) : null}
-
-
+ >
+ {onOpenWeworkForAppshot && active && !iframe ? (
+
+ ) : null}
+ {renderWorkbench ? (
+
+
+
+ ) : null}
+ {auxiliaryPage ? (
+
+ {auxiliaryPage}
+
+ ) : null}
+
+ ) : null}
+ {renderedIframe ? (
+
+ ) : null}
+
+
+
)
}
@@ -295,6 +300,10 @@ function AppRoutes({ onWorkbenchStartupReadyChange, onOpenWeworkForAppshot }: Ap
})
}
+ useLayoutEffect(() => {
+ setActiveWorkspaceTabPortalOwner(workspaceTabs?.activeTabId ?? null)
+ }, [workspaceTabs?.activeTabId])
+
useEffect(() => {
if (path === '/automations' && experimentalFeatures.loaded && !experimentalFeatures.enabled) {
navigateTo('/')
diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
index 20e15eeae0..4a8296b2b6 100644
--- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
+++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
@@ -869,7 +869,9 @@ describe('DesktopWorkbenchLayout', () => {
onLogout?: () => void
}
- function DesktopWorkbenchLayout(props: LegacyDesktopWorkbenchLayoutProps) {
+ function DesktopWorkbenchLayout(
+ props: LegacyDesktopWorkbenchLayoutProps & { routeActive?: boolean }
+ ) {
const { authValue, workbenchValue, paneValue, paneSession } = createWorkbenchMocks(props)
paneSessionMockRef.current = paneSession
const lifecycleTaskRunning =
@@ -893,7 +895,7 @@ describe('DesktopWorkbenchLayout', () => {
-
+
@@ -2089,6 +2091,28 @@ describe('DesktopWorkbenchLayout', () => {
}
})
+ test('does not publish titlebar actions from an inactive workspace document tab', () => {
+ const previousTauriInternals = (window as typeof window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__
+ Object.defineProperty(window, '__TAURI_INTERNALS__', {
+ configurable: true,
+ value: {},
+ })
+
+ try {
+ render()
+
+ expect(screen.getByTestId('workbench-main-header')).toBeEmptyDOMElement()
+ expect(screen.queryByTestId('titlebar-main-actions')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('titlebar-actions')).not.toBeInTheDocument()
+ } finally {
+ Object.defineProperty(window, '__TAURI_INTERNALS__', {
+ configurable: true,
+ value: previousTauriInternals,
+ })
+ }
+ })
+
test('hides continue-in-im action without a runtime task', () => {
const onListImPrivateSessions = vi.fn().mockResolvedValue({ total: 0, items: [] })
diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx
index 594bdf2a40..86a6d729db 100644
--- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx
+++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx
@@ -59,7 +59,11 @@ function boardProjectIdFromRoute(contentRoute: string): string | null {
return new URLSearchParams(contentRoute.slice(searchIndex + 1)).get('projectId')
}
-export function DesktopWorkbenchLayout() {
+interface DesktopWorkbenchLayoutProps {
+ routeActive?: boolean
+}
+
+export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchLayoutProps) {
const { t } = useTranslation('common')
const { logout: onLogout } = useAuth()
const {
@@ -714,7 +718,7 @@ export function DesktopWorkbenchLayout() {
))}
{
+ setActiveWorkspaceTabPortalOwner(activeOwner)
+ }, [activeOwner])
+
+ return (
+ <>
+
+
+
+
+ First
+
+
+
+
+
+
+ Second
+
+
+
+ >
+ )
+}
+
+describe('workspace tab titlebar portal ownership', () => {
+ test('hides stale portal content when React keeps an inactive Activity mounted', async () => {
+ const { rerender } = render()
+
+ await waitFor(() => expect(screen.getByTestId('first-portal')).toBeVisible())
+
+ rerender()
+
+ await waitFor(() => expect(screen.getByTestId('second-portal')).toBeVisible())
+ expect(screen.getByTestId('first-portal').parentElement).toHaveAttribute('hidden')
+ expect(screen.getByTestId('second-portal').parentElement).not.toHaveAttribute('hidden')
+ })
+})
diff --git a/wework/src/components/topnav/TitlebarActionsPortal.tsx b/wework/src/components/topnav/TitlebarActionsPortal.tsx
index 3e34c40d26..011bbd4666 100644
--- a/wework/src/components/topnav/TitlebarActionsPortal.tsx
+++ b/wework/src/components/topnav/TitlebarActionsPortal.tsx
@@ -1,5 +1,9 @@
-import { useSyncExternalStore, type ReactNode } from 'react'
+import { createContext, useCallback, useContext, useSyncExternalStore, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
+import {
+ getActiveWorkspaceTabPortalOwner,
+ syncWorkspaceTabPortalOwnerElement,
+} from './workspaceTabPortalOwnership'
export const TITLEBAR_ACTIONS_PORTAL_ID = 'titlebar-actions-portal'
export const TITLEBAR_FEEDBACK_PORTAL_ID = 'titlebar-feedback-portal'
@@ -10,10 +14,26 @@ interface TitlebarActionsPortalProps {
children: ReactNode
}
+const WorkspaceTabPortalOwnerContext = createContext(null)
+
+export function WorkspaceTabPortalOwner({
+ children,
+ ownerId,
+}: {
+ children: ReactNode
+ ownerId: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
export function TitlebarActionsPortal({ children }: TitlebarActionsPortalProps) {
const portalTarget = useSyncExternalStore(subscribeToPortalTarget, getPortalTarget, () => null)
- return portalTarget ? createPortal(children, portalTarget) : null
+ return portalTarget ? createOwnedPortal(children, portalTarget) : null
}
export function TitlebarFeedbackPortal({ children }: TitlebarActionsPortalProps) {
@@ -23,7 +43,7 @@ export function TitlebarFeedbackPortal({ children }: TitlebarActionsPortalProps)
() => null
)
- return portalTarget ? createPortal(children, portalTarget) : null
+ return portalTarget ? createOwnedPortal(children, portalTarget) : null
}
export function TitlebarRightPanelPortal({ children }: TitlebarActionsPortalProps) {
@@ -33,7 +53,7 @@ export function TitlebarRightPanelPortal({ children }: TitlebarActionsPortalProp
() => null
)
- return portalTarget ? createPortal(children, portalTarget) : null
+ return portalTarget ? createOwnedPortal(children, portalTarget) : null
}
export function WorkbenchMainHeaderPortal({ children }: TitlebarActionsPortalProps) {
@@ -43,13 +63,42 @@ export function WorkbenchMainHeaderPortal({ children }: TitlebarActionsPortalPro
() => null
)
- return portalTarget ? createPortal(children, portalTarget) : null
+ return portalTarget ? createOwnedPortal(children, portalTarget) : null
}
function getPortalTarget() {
return document.getElementById(TITLEBAR_ACTIONS_PORTAL_ID)
}
+function createOwnedPortal(children: ReactNode, portalTarget: HTMLElement) {
+ return createPortal({children}, portalTarget)
+}
+
+function WorkspaceTabOwnedPortal({ children }: { children: ReactNode }) {
+ const ownerId = useContext(WorkspaceTabPortalOwnerContext)
+ const syncVisibility = useCallback(
+ (element: HTMLDivElement | null) => {
+ if (!element || !ownerId) return
+ syncWorkspaceTabPortalOwnerElement(element, ownerId)
+ },
+ [ownerId]
+ )
+
+ if (!ownerId) return children
+
+ const active = ownerId === getActiveWorkspaceTabPortalOwner()
+ return (
+
+ {children}
+
+ )
+}
+
function subscribeToPortalTarget(onStoreChange: () => void) {
const observer = new MutationObserver(onStoreChange)
observer.observe(document.body, { childList: true, subtree: true })
diff --git a/wework/src/components/topnav/workspaceTabPortalOwnership.ts b/wework/src/components/topnav/workspaceTabPortalOwnership.ts
new file mode 100644
index 0000000000..025536e040
--- /dev/null
+++ b/wework/src/components/topnav/workspaceTabPortalOwnership.ts
@@ -0,0 +1,21 @@
+let activeWorkspaceTabPortalOwner: string | null = null
+
+export function getActiveWorkspaceTabPortalOwner() {
+ return activeWorkspaceTabPortalOwner
+}
+
+export function syncWorkspaceTabPortalOwnerElement(element: HTMLElement, ownerId: string) {
+ const active = ownerId === activeWorkspaceTabPortalOwner
+ element.hidden = !active
+ element.style.display = active ? 'contents' : 'none'
+}
+
+export function setActiveWorkspaceTabPortalOwner(ownerId: string | null) {
+ activeWorkspaceTabPortalOwner = ownerId
+ // Hidden React Activities defer child updates, while their portals remain attached to
+ // global titlebar targets. Update the portal wrappers from the active tab boundary.
+ document.querySelectorAll('[data-workspace-tab-portal-owner]').forEach(element => {
+ const elementOwnerId = element.dataset.workspaceTabPortalOwner
+ if (elementOwnerId) syncWorkspaceTabPortalOwnerElement(element, elementOwnerId)
+ })
+}
diff --git a/wework/src/pages/WorkbenchPage.tsx b/wework/src/pages/WorkbenchPage.tsx
index a9a13353d2..3831d26f7d 100644
--- a/wework/src/pages/WorkbenchPage.tsx
+++ b/wework/src/pages/WorkbenchPage.tsx
@@ -96,7 +96,7 @@ export function WorkbenchPage({ routeActive = true }: WorkbenchPageProps) {
return shouldUseMobileWorkbenchLayout({ isMobileViewport, isTauri }) ? (
) : (
-
+
)
}