Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/en/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 缓存。
Expand Down
103 changes: 56 additions & 47 deletions wework/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Activity,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -226,54 +229,56 @@ function WorkspaceTabSurface({
const usesAuxiliaryDesktopSurface = auxiliaryActive && isTauriRuntime()

return (
<Activity mode={active ? 'visible' : 'hidden'}>
<div
className="h-full"
data-testid={`workspace-tab-content-${tab.id}`}
data-workspace-tab-content={tab.id}
>
{renderProvider ? (
<WorkbenchProvider
user={user}
onStartupReadyChange={active && !iframe ? onWorkbenchStartupReadyChange : undefined}
workspaceTabId={tab.id}
>
{onOpenWeworkForAppshot && active && !iframe ? (
<AppshotBridge onOpenWework={onOpenWeworkForAppshot} />
) : null}
{renderWorkbench ? (
<div
className={cn('h-full', !nativeWorkbenchActive && 'hidden')}
aria-hidden={!nativeWorkbenchActive}
>
<WorkbenchPage routeActive={active && nativeWorkbenchActive} />
</div>
) : null}
{auxiliaryPage ? (
<div
data-testid="desktop-auxiliary-surface"
className={cn(
'h-full',
usesAuxiliaryDesktopSurface &&
'app-view-surface overflow-hidden rounded-xl border border-border/60 bg-background shadow-[0_3px_16px_rgba(0,0,0,0.04)]'
)}
>
{auxiliaryPage}
</div>
) : null}
</WorkbenchProvider>
) : null}
{renderedIframe ? (
<div className={cn('h-full', !iframe && 'hidden')} aria-hidden={!iframe}>
<AppIframe
src={renderedIframe.src}
title={renderedIframe.title}
<WorkspaceTabPortalOwner ownerId={tab.id}>
<Activity mode={active ? 'visible' : 'hidden'}>
<div
className="h-full"
data-testid={`workspace-tab-content-${tab.id}`}
data-workspace-tab-content={tab.id}
>
{renderProvider ? (
<WorkbenchProvider
user={user}
onStartupReadyChange={active && !iframe ? onWorkbenchStartupReadyChange : undefined}
workspaceTabId={tab.id}
/>
</div>
) : null}
</div>
</Activity>
>
{onOpenWeworkForAppshot && active && !iframe ? (
<AppshotBridge onOpenWework={onOpenWeworkForAppshot} />
) : null}
{renderWorkbench ? (
<div
className={cn('h-full', !nativeWorkbenchActive && 'hidden')}
aria-hidden={!nativeWorkbenchActive}
>
<WorkbenchPage routeActive={active && nativeWorkbenchActive} />
</div>
) : null}
{auxiliaryPage ? (
<div
data-testid="desktop-auxiliary-surface"
className={cn(
'h-full',
usesAuxiliaryDesktopSurface &&
'app-view-surface overflow-hidden rounded-xl border border-border/60 bg-background shadow-[0_3px_16px_rgba(0,0,0,0.04)]'
)}
>
{auxiliaryPage}
</div>
) : null}
</WorkbenchProvider>
) : null}
{renderedIframe ? (
<div className={cn('h-full', !iframe && 'hidden')} aria-hidden={!iframe}>
<AppIframe
src={renderedIframe.src}
title={renderedIframe.title}
workspaceTabId={tab.id}
/>
</div>
) : null}
</div>
</Activity>
</WorkspaceTabPortalOwner>
)
}

Expand All @@ -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('/')
Expand Down
28 changes: 26 additions & 2 deletions wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -893,7 +895,7 @@ describe('DesktopWorkbenchLayout', () => {
<AuthContext.Provider value={authValue}>
<WorkbenchContext.Provider value={workbenchValue}>
<WorkbenchPaneContext.Provider value={paneValue}>
<ActualDesktopWorkbenchLayout />
<ActualDesktopWorkbenchLayout routeActive={props.routeActive} />
</WorkbenchPaneContext.Provider>
</WorkbenchContext.Provider>
</AuthContext.Provider>
Expand Down Expand Up @@ -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(<DesktopWorkbenchLayout {...baseProps} routeActive={false} />)

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: [] })

Expand Down
8 changes: 6 additions & 2 deletions wework/src/components/layout/DesktopWorkbenchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -714,7 +718,7 @@ export function DesktopWorkbenchLayout() {
))}
<div style={{ display: todoOpen ? 'none' : 'contents' }} aria-hidden={todoOpen}>
<DesktopWorkbenchMain
visible={!settingsOpen && !todoOpen}
visible={routeActive && !settingsOpen && !todoOpen}
sidebarCollapsed={effectiveSidebarCollapsed}
sidebarResizing={sidebarResizing}
onSidebarCollapsedChange={updateSidebarCollapsed}
Expand Down
49 changes: 49 additions & 0 deletions wework/src/components/topnav/TitlebarActionsPortal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Activity, useLayoutEffect } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import {
TITLEBAR_FEEDBACK_PORTAL_ID,
TitlebarFeedbackPortal,
WorkspaceTabPortalOwner,
} from './TitlebarActionsPortal'
import { setActiveWorkspaceTabPortalOwner } from './workspaceTabPortalOwnership'

function PortalHarness({ activeOwner }: { activeOwner: 'first' | 'second' }) {
useLayoutEffect(() => {
setActiveWorkspaceTabPortalOwner(activeOwner)
}, [activeOwner])

return (
<>
<div id={TITLEBAR_FEEDBACK_PORTAL_ID} />
<WorkspaceTabPortalOwner ownerId="first">
<Activity mode={activeOwner === 'first' ? 'visible' : 'hidden'}>
<TitlebarFeedbackPortal>
<span data-testid="first-portal">First</span>
</TitlebarFeedbackPortal>
</Activity>
</WorkspaceTabPortalOwner>
<WorkspaceTabPortalOwner ownerId="second">
<Activity mode={activeOwner === 'second' ? 'visible' : 'hidden'}>
<TitlebarFeedbackPortal>
<span data-testid="second-portal">Second</span>
</TitlebarFeedbackPortal>
</Activity>
</WorkspaceTabPortalOwner>
</>
)
}

describe('workspace tab titlebar portal ownership', () => {
test('hides stale portal content when React keeps an inactive Activity mounted', async () => {
const { rerender } = render(<PortalHarness activeOwner="first" />)

await waitFor(() => expect(screen.getByTestId('first-portal')).toBeVisible())

rerender(<PortalHarness activeOwner="second" />)

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')
})
})
59 changes: 54 additions & 5 deletions wework/src/components/topnav/TitlebarActionsPortal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,10 +14,26 @@ interface TitlebarActionsPortalProps {
children: ReactNode
}

const WorkspaceTabPortalOwnerContext = createContext<string | null>(null)

export function WorkspaceTabPortalOwner({
children,
ownerId,
}: {
children: ReactNode
ownerId: string
}) {
return (
<WorkspaceTabPortalOwnerContext.Provider value={ownerId}>
{children}
</WorkspaceTabPortalOwnerContext.Provider>
)
}

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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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(<WorkspaceTabOwnedPortal>{children}</WorkspaceTabOwnedPortal>, 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 (
<div
ref={syncVisibility}
data-workspace-tab-portal-owner={ownerId}
hidden={!active}
style={{ display: active ? 'contents' : 'none' }}
>
{children}
</div>
)
}

function subscribeToPortalTarget(onStoreChange: () => void) {
const observer = new MutationObserver(onStoreChange)
observer.observe(document.body, { childList: true, subtree: true })
Expand Down
Loading
Loading