From e83be5d9e52e466d0abb26528f00a9ab7441595f Mon Sep 17 00:00:00 2001 From: axb Date: Sat, 1 Aug 2026 00:40:14 +0800 Subject: [PATCH 01/11] feat(wework): add workspace document tabs --- design-qa.md | 44 +++ wework/e2e/desktop/task-flow.e2e.mjs | 46 +++ wework/scripts/ai-verify.mjs | 6 +- wework/src-tauri/capabilities/default.json | 2 +- wework/src-tauri/src/desktop_capture.rs | 20 ++ wework/src-tauri/src/lib.rs | 1 + wework/src/App.apps.test.tsx | 16 +- wework/src/App.tsx | 97 ++++-- .../layout/DesktopAppSwitcher.test.tsx | 10 +- .../components/layout/DesktopAppSwitcher.tsx | 2 +- .../layout/DesktopWorkbenchLayout.tsx | 29 ++ .../components/topnav/ChromeTitlebar.test.tsx | 211 +++--------- .../src/components/topnav/ChromeTitlebar.tsx | 52 +-- wework/src/e2e/automation.ts | 12 + .../features/todo/CloudTodoWorkspace.test.tsx | 28 ++ .../src/features/todo/CloudTodoWorkspace.tsx | 26 +- .../workspace-tabs/WorkspaceTabStrip.test.tsx | 91 ++++++ .../workspace-tabs/WorkspaceTabStrip.tsx | 295 +++++++++++++++++ .../workspace-tabs/WorkspaceTabsContext.tsx | 301 ++++++++++++++++++ .../workspace-tabs/workspaceTabs.test.ts | 79 +++++ .../features/workspace-tabs/workspaceTabs.ts | 135 ++++++++ .../workspaceTabsContextValue.ts | 27 ++ .../workspace-tabs/workspaceWindow.test.ts | 76 +++++ .../workspace-tabs/workspaceWindow.ts | 72 +++++ wework/src/i18n/locales/en/common.json | 9 +- wework/src/i18n/locales/zh-CN/common.json | 9 +- wework/src/styles/globals.css | 25 ++ 27 files changed, 1445 insertions(+), 276 deletions(-) create mode 100644 wework/src/features/workspace-tabs/WorkspaceTabStrip.test.tsx create mode 100644 wework/src/features/workspace-tabs/WorkspaceTabStrip.tsx create mode 100644 wework/src/features/workspace-tabs/WorkspaceTabsContext.tsx create mode 100644 wework/src/features/workspace-tabs/workspaceTabs.test.ts create mode 100644 wework/src/features/workspace-tabs/workspaceTabs.ts create mode 100644 wework/src/features/workspace-tabs/workspaceTabsContextValue.ts create mode 100644 wework/src/features/workspace-tabs/workspaceWindow.test.ts create mode 100644 wework/src/features/workspace-tabs/workspaceWindow.ts diff --git a/design-qa.md b/design-qa.md index 1d1787ff6e..24d33c5744 100644 --- a/design-qa.md +++ b/design-qa.md @@ -14,6 +14,50 @@ final result: passed +--- + +# Wework 工作区标签设计 QA + +## 目标 + +- 参考图:`/Users/axb-mac/.wegent-executor/workspace/attachments/draft/1785512913569/image.png` +- 实现范围:Wework 顶层任务 / 项目空间文档标签、项目名标题、关闭与恢复、新窗口。 +- 验证环境:macOS 真实 Tauri 隔离实例,主窗口 1280×720;独立项目窗口 1280×800。 + +## 对照结果 + +- 对照图:`wework/test-results/ai-verify/final-workspace-tabs/10-reference-vs-implementation.png` +- 标题栏高度、文档标签宽度、圆角连接、关闭按钮、加号位置与参考方向一致。 +- 标题栏使用稳定浅灰底色,选中标签使用内容表面色;未选中标签留在标题栏层,状态差异清晰。 +- 顶层产品名统一为“项目空间”;具体项目打开后标签标题显示项目名,例如“产品规划”。 +- 主窗口与独立项目窗口均无裁切、重叠、错误边框或圆角断裂。 + +## 核心流程截图 + +1. `01-task-active.png`:任务标签初始选中。 +2. `02-new-tab-menu.png`:新建标签菜单。 +3. `03-project-spaces-active.png`:项目空间顶层标签选中。 +4. `04-project-name-active.png`:具体项目标题显示“产品规划”。 +5. `05-switch-back-to-task.png`:切回任务,选中态同步。 +6. `06-close-project-tab.png`:关闭项目标签后回退到任务。 +7. `07-restore-closed-project-tab.png`:快捷键恢复已关闭项目标签。 +8. `08-tab-context-menu.png`:标签上下文菜单。 +9. `09-project-new-window.png`:项目在独立窗口中打开。 + +截图目录:`wework/test-results/ai-verify/final-workspace-tabs/` + +## 缺陷审查 + +- P0:选中与未选中标签底色过于接近。已拆分标题栏和活动标签表面色,并重新截图验证。 +- P0:新建项目后标签仍显示“项目空间”。已改为直接使用创建结果更新活动标签,真实流程与单测均通过。 +- P1:右键菜单只能依赖鼠标。已增加 Shift+F10 / Context Menu 键入口。 +- P1:动态项目窗口无法纳入隔离截图链。已增加工作区窗口 WebView 截图命令并验证。 +- P2:未发现。 + +## 最终结论 + +final result: passed + ## Dropdown alignment follow-up - Source visual truth: `/Users/axb-mac/.wegent-executor/workspace/attachments/draft/1785311314363/image.png` diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index 691c746dce..040e0ecd50 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -2911,6 +2911,49 @@ async function captureVerificationScreenshot(control, name, selector = 'body') { return screenshotPath } +async function verifyWorkspaceDocumentTabs(control) { + await control.command('waitFor', '[data-testid="workspace-tab-strip"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('waitFor', '[data-tab-kind="task"][aria-selected="true"]', { + text: '任务', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + + await control.command('click', '[data-testid="workspace-tab-add"]') + await control.command('waitFor', '[data-testid="workspace-tab-add-menu"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('click', '[data-testid="workspace-tab-add-board"]') + await control.command('waitFor', '[data-tab-kind="board"][aria-selected="true"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('waitFor', '[data-testid="cloud-todo-workspace"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'workspace-tabs-01-project-spaces-active.png') + + await control.command( + 'click', + '[data-tab-kind="task"] [data-testid^="workspace-tab-select-"]' + ) + await control.command('waitFor', '[data-tab-kind="task"][aria-selected="true"]', { + text: '任务', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command( + 'click', + '[data-tab-kind="board"] [data-testid^="workspace-tab-close-"]' + ) + const snapshot = JSON.parse(await control.command('snapshot', 'body')) + assert.equal( + snapshot.testIds.some(testId => testId.startsWith('workspace-tab-board-')), + false, + 'Closing the project-space document tab did not remove it from the titlebar' + ) + await captureVerificationScreenshot(control, 'workspace-tabs-02-task-restored.png') +} + async function verifyCloudWorkPage(control) { await control.command('navigate', 'body', { value: '/cloud-work' }) await control.command('waitFor', '[data-testid="cloud-work-page"]', { @@ -10544,6 +10587,9 @@ last_updated = "2026-07-30T00:00:00Z"` if (shouldRunDesktopCheckpoint('core-task-flow')) { if (!GUIDANCE_SCROLL_ONLY) { + phase = 'workspace-document-tabs' + await verifyWorkspaceDocumentTabs(control) + phase = 'automation-lifecycle' await verifyAutomationLifecycle(control, workspacePath) diff --git a/wework/scripts/ai-verify.mjs b/wework/scripts/ai-verify.mjs index 3f53a44b0c..fc170f9f6a 100644 --- a/wework/scripts/ai-verify.mjs +++ b/wework/scripts/ai-verify.mjs @@ -27,7 +27,7 @@ const corsHeaders = { function usage() { console.error(`Usage: pnpm --filter wework ai:verify start - pnpm --filter wework ai:verify --session PATH [options] + pnpm --filter wework ai:verify --session PATH [options] Options: --codex-home-initialization true @@ -401,6 +401,7 @@ async function main() { const action = { capture: 'capture', 'capture-popout': 'capturePopoutWindow', + 'capture-workspace': 'captureWorkspaceWindow', snapshot: 'snapshot', debug: 'getWorkbenchDebugSnapshot', click: 'click', @@ -438,6 +439,7 @@ async function main() { options.selector ?? (command === 'capture' || command === 'capture-popout' || + command === 'capture-workspace' || command === 'snapshot' || command === 'debug' || command === 'click-at' || @@ -490,7 +492,7 @@ async function main() { effectiveTimeoutMs ) } - if (command === 'capture' || command === 'capture-popout') { + if (command === 'capture' || command === 'capture-popout' || command === 'capture-workspace') { if (!options.output) throw new Error('--output is required') const prefix = 'data:image/png;base64,' if (!value.value?.startsWith(prefix)) throw new Error('Invalid screenshot payload') diff --git a/wework/src-tauri/capabilities/default.json b/wework/src-tauri/capabilities/default.json index a4283f3a54..2dfc854981 100644 --- a/wework/src-tauri/capabilities/default.json +++ b/wework/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main", "popout-window"], + "windows": ["main", "popout-window", "workspace-*"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/wework/src-tauri/src/desktop_capture.rs b/wework/src-tauri/src/desktop_capture.rs index 1b355215d2..c1251e28c7 100644 --- a/wework/src-tauri/src/desktop_capture.rs +++ b/wework/src-tauri/src/desktop_capture.rs @@ -19,6 +19,26 @@ pub async fn capture_popout_webview(app: tauri::AppHandle) -> Result Result { + use tauri::Manager; + + let label = app + .webview_windows() + .into_iter() + .find_map(|(label, window)| { + (label.starts_with("workspace-") && window.is_focused().unwrap_or(false)) + .then_some(label) + }) + .or_else(|| { + app.webview_windows() + .into_keys() + .find(|label| label.starts_with("workspace-")) + }) + .ok_or_else(|| "No workspace window is available".to_string())?; + capture_webview_impl(app, &label, false).await +} + #[cfg(target_os = "macos")] pub(crate) async fn capture_embedded_webview_png( webview: tauri::Webview, diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 690d89c95d..62614ebec2 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -4527,6 +4527,7 @@ pub fn run() { cloud_authorization_window::position_cloud_authorization_window, desktop_capture::capture_main_webview, desktop_capture::capture_popout_webview, + desktop_capture::capture_workspace_webview, acknowledge_frontend_resume_probe, register_frontend_recovery_bridge, #[cfg(desktop)] diff --git a/wework/src/App.apps.test.tsx b/wework/src/App.apps.test.tsx index b2d85e0814..fd35156883 100644 --- a/wework/src/App.apps.test.tsx +++ b/wework/src/App.apps.test.tsx @@ -202,7 +202,7 @@ describe('App center route', () => { }) } - test('renders the app center with fixed titlebar tabs on the app route', async () => { + test('renders the app center in an Apps document tab', async () => { window.history.pushState({}, '', '/apps') render() @@ -210,14 +210,14 @@ describe('App center route', () => { await waitForStartupScreenToClose() await waitFor(() => expect(window.location.pathname).toBe('/apps')) - expect(screen.getByTestId('desktop-app-switcher')).toHaveTextContent('任务') + expect(screen.getByTestId('workspace-tab-strip')).toHaveTextContent('Apps') + expect(screen.getByRole('tab', { name: /Apps/ })).toHaveAttribute('aria-selected', 'true') expect(screen.getByTestId('desktop-auxiliary-surface')).toHaveClass( 'app-view-surface', 'rounded-xl', 'border' ) - expect(screen.queryByTestId('chrome-tab-todo')).not.toBeInTheDocument() - expect(screen.queryByTestId('chrome-tab-apps')).not.toBeInTheDocument() + expect(screen.getByTestId('workspace-tab-add')).toBeInTheDocument() expect(screen.getByTestId('apps-page')).toBeInTheDocument() expect(screen.getByRole('heading', { name: '管理你的办公与编码应用' })).toBeInTheDocument() expect(await screen.findByText('Executor 状态')).toBeInTheDocument() @@ -339,7 +339,7 @@ describe('App center route', () => { expect(sectionTabs).not.toHaveClass('xl:hidden') }) - test('uses the fixed global view switcher on the app center route', async () => { + test('uses the document tab strip on the app center route', async () => { window.history.pushState({}, '', '/apps') render() @@ -347,10 +347,8 @@ describe('App center route', () => { await waitForStartupScreenToClose() expect(await screen.findByText('Executor 状态')).toBeInTheDocument() - const weworkTab = screen.getByTestId('chrome-tab-wework') - expect(screen.queryByTestId('chrome-tab-todo')).not.toBeInTheDocument() - expect(screen.queryByTestId('chrome-tab-apps')).not.toBeInTheDocument() - expect(weworkTab).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Apps/ })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('workspace-tab-add')).toBeInTheDocument() expect(screen.getByTestId('apps-sidebar-nav')).toBeInTheDocument() }) diff --git a/wework/src/App.tsx b/wework/src/App.tsx index 1cf354f690..57ab6583e6 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useMemo, useRef, useState, type CSSProperties, @@ -77,6 +78,8 @@ import { useExperimentalFeaturesState } from '@/features/experimental-features/u import { AppPreferencesProvider } from '@/features/app-preferences/AppPreferencesProvider' import { useAppPreferencesState } from '@/features/app-preferences/useAppPreferencesState' import { getCurrentWindow } from '@tauri-apps/api/window' +import { useTranslation } from '@/hooks/useTranslation' +import { WorkspaceTabsProvider } from '@/features/workspace-tabs/WorkspaceTabsContext' const WORKBENCH_STARTUP_REVEAL_TIMEOUT_MS = 6000 const POPOUT_WINDOW_LABEL = 'popout-window' @@ -97,15 +100,26 @@ function hasTauriIpc() { } function useCurrentPath() { - const [path, setPath] = useState(stripAppBasePath(window.location.pathname)) + return useCurrentLocation().pathname +} + +function useCurrentLocation() { + const [location, setLocation] = useState(() => ({ + pathname: stripAppBasePath(window.location.pathname), + search: window.location.search, + })) useEffect(() => { - const handlePopState = () => setPath(stripAppBasePath(window.location.pathname)) + const handlePopState = () => + setLocation({ + pathname: stripAppBasePath(window.location.pathname), + search: window.location.search, + }) window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) }, []) - return path + return location } interface AppRoutesProps { @@ -288,7 +302,8 @@ function LanguagePreferenceInitializer() { } function AppShell() { - const path = useCurrentPath() + const { t } = useTranslation('common') + const { pathname: path, search } = useCurrentLocation() const { user, isLoading } = useAuth() const cloudConnection = useCloudConnection() const initialCloudConnection = { @@ -296,13 +311,22 @@ function AppShell() { isConnected: cloudConnection.isConnected, token: cloudConnection.token, } - const { activeAppKey, tabs, navigateToApp } = useChromeTabs(path) + const { activeAppKey, navigateToApp } = useChromeTabs(path) const isTauri = isTauriRuntime() const isPopoutWindow = isPopoutWindowRuntime() const usesDesktopVibrancy = isTauri && !isPopoutWindow && getPlatform() === 'mac' const titlebarOverlaysContent = false const showChromeTitlebar = isTauri && !isPopoutWindow - const titlebarActiveKey = path === '/todo' ? 'todo' : activeAppKey + const workspaceTabStorageScope = isTauri ? getCurrentWindow().label : 'browser' + const workspaceTabLabels = useMemo( + () => ({ + task: t('workbench.workspace_tab_task', '任务'), + board: t('workbench.workspace_tab_board', '项目空间'), + agent: t('workbench.workspace_tab_agent', '智能体'), + auxiliary: t('workbench.workspace_tab_auxiliary', '工作区'), + }), + [t] + ) const [workbenchStartupReady, setWorkbenchStartupReady] = useState(false) const [workbenchStartupRevealTimedOut, setWorkbenchStartupRevealTimedOut] = useState(false) const openWeworkForAppshot = useCallback(() => { @@ -496,41 +520,44 @@ function AppShell() { } const shell = ( -
- {showChromeTitlebar && ( - (appKey === 'todo' ? navigateTo('/todo') : navigateToApp(appKey))} - iconOnlyTabs={isTauri} - showWorkspacePortals={activeAppKey !== 'wework'} - showFeedback={activeAppKey !== 'wework'} - /> - )}
- + {showChromeTitlebar && ( + + )} +
+ +
+ {!isPopoutWindow && }
- {!isPopoutWindow && } -
+ ) if (isPopoutWindow) { diff --git a/wework/src/components/layout/DesktopAppSwitcher.test.tsx b/wework/src/components/layout/DesktopAppSwitcher.test.tsx index 41f2efb273..2f458d7ce4 100644 --- a/wework/src/components/layout/DesktopAppSwitcher.test.tsx +++ b/wework/src/components/layout/DesktopAppSwitcher.test.tsx @@ -53,7 +53,7 @@ describe('DesktopAppSwitcher', () => { render() expect(screen.getByTestId('chrome-tab-wework')).toHaveAttribute('aria-current', 'page') - expect(screen.getByTestId('chrome-tab-todo')).toHaveTextContent('看板') + expect(screen.getByTestId('chrome-tab-todo')).toHaveTextContent('项目空间') expect(screen.getByTestId('chrome-tab-todo')).not.toHaveAttribute('aria-haspopup') fireEvent.click(screen.getByTestId('chrome-tab-todo')) expect(onNavigate).toHaveBeenCalledWith('todo') @@ -115,11 +115,11 @@ describe('DesktopAppSwitcher', () => { .map(option => option.textContent) ).toEqual([ '任务使用 AI 解决具体问题', - '看板用 AI 管理项目的规划、执行与反馈', + '项目空间用 AI 管理项目的规划、执行与反馈', '智能体构建并交付可嵌入业务的云端智能体', ]) expect(screen.getByTestId('app-switcher-option-todo')).toHaveTextContent( - '看板用 AI 管理项目的规划、执行与反馈' + '项目空间用 AI 管理项目的规划、执行与反馈' ) }) @@ -138,7 +138,7 @@ describe('DesktopAppSwitcher', () => { .map(option => option.textContent) ).toEqual([ '任务使用 AI 解决具体问题', - '看板用 AI 管理项目的规划、执行与反馈', + '项目空间用 AI 管理项目的规划、执行与反馈', '智能体构建并交付可嵌入业务的云端智能体', ]) }) @@ -162,7 +162,7 @@ describe('DesktopAppSwitcher', () => { experimentalFeatures.enabled = true render() - expect(screen.getByTestId('desktop-app-switcher')).toHaveTextContent('看板') + expect(screen.getByTestId('desktop-app-switcher')).toHaveTextContent('项目空间') }) test('opens settings from the switcher menu', () => { diff --git a/wework/src/components/layout/DesktopAppSwitcher.tsx b/wework/src/components/layout/DesktopAppSwitcher.tsx index c91aebc45a..c1cdc530f5 100644 --- a/wework/src/components/layout/DesktopAppSwitcher.tsx +++ b/wework/src/components/layout/DesktopAppSwitcher.tsx @@ -136,7 +136,7 @@ export function DesktopAppSwitcher({ if (experimentalFeaturesEnabled || activeApp === 'todo') { appOptions.push({ key: 'todo', - label: t('workbench.app_weloop_label', '看板'), + label: t('workbench.app_weloop_label', '项目空间'), description: t('workbench.app_weloop_description', '用 AI 管理项目的规划、执行与反馈'), }) } diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index 5ba623d441..594bdf2a40 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -38,6 +38,7 @@ import { resolveLocalTodoProjects } from '@/features/todo/localTodoProjects' import { WorkbenchBackground } from '@/features/appearance' import { isTauriRuntime } from '@/lib/runtime-environment' import { useResizableSidebar } from './useResizableSidebar' +import { useOptionalWorkspaceTabs } from '@/features/workspace-tabs/workspaceTabsContextValue' type ImNotificationDialogMode = { type: 'global' } | { type: 'task'; address: RuntimeTaskAddress } @@ -52,6 +53,12 @@ function getPermanentWorktreeError(error: unknown, fallback: string) { return fallback } +function boardProjectIdFromRoute(contentRoute: string): string | null { + const searchIndex = contentRoute.indexOf('?') + if (searchIndex < 0) return null + return new URLSearchParams(contentRoute.slice(searchIndex + 1)).get('projectId') +} + export function DesktopWorkbenchLayout() { const { t } = useTranslation('common') const { logout: onLogout } = useAuth() @@ -107,6 +114,7 @@ export function DesktopWorkbenchLayout() { () => resolveLocalTodoProjects(state.projects, state.runtimeWork), [state.projects, state.runtimeWork] ) + const workspaceTabs = useOptionalWorkspaceTabs() const initialPath = stripAppBasePath(window.location.pathname) const [currentPath, setCurrentPath] = useState(initialPath) const todoOpen = currentPath === '/todo' @@ -674,6 +682,27 @@ export function DesktopWorkbenchLayout() { user={state.user} localProjects={localTodoProjects} services={services} + activeProjectId={ + workspaceTabs?.activeTab.kind === 'board' + ? boardProjectIdFromRoute(workspaceTabs.activeTab.contentRoute) + : undefined + } + onActiveProjectChange={project => { + if (!workspaceTabs || workspaceTabs.activeTab.kind !== 'board') return + if (!project) { + workspaceTabs.updateActiveTab({ + title: t('workbench.workspace_tab_board', '项目空间'), + contentRoute: '/todo', + }) + return + } + const params = new URLSearchParams() + params.set('projectId', project.id) + workspaceTabs.updateActiveTab({ + title: project.name, + contentRoute: `/todo?${params.toString()}`, + }) + }} /> ) : (
({ TaskFeedbackDialog: () =>
FeedbackDialog
, })) -const mockTabs: AppTab[] = [ - { key: 'wework', label: 'WeWork', mode: 'native', requiresAuth: true }, - { - key: 'wegent', - label: 'Wegent', - mode: 'iframe', - url: 'http://localhost:3000', - requiresAuth: true, - }, -] - -const appTabs: AppTab[] = [ - { key: 'wework', label: 'WeWork', mode: 'native', requiresAuth: true }, - { key: 'apps', label: '应用', mode: 'native', requiresAuth: true }, -] +const labels = { + task: '任务', + board: '项目空间', + agent: '智能体', + auxiliary: '工作区', +} + +function renderTitlebar(props: React.ComponentProps = {}) { + return render( + + + + ) +} function mockUserAgent(ua: string) { Object.defineProperty(navigator, 'userAgent', { @@ -54,180 +52,57 @@ function disableTauri() { describe('ChromeTitlebar', () => { beforeEach(() => { startDragging.mockClear() + localStorage.clear() + disableTauri() + mockUserAgent('Mozilla/5.0') + window.history.replaceState({}, '', '/') }) - test('renders all tab buttons', () => { - render() - expect(screen.getByTestId('chrome-tab-wework')).toBeInTheDocument() - expect(screen.getByTestId('chrome-tab-wegent')).toBeInTheDocument() - }) + test('renders the document tab strip and titlebar slots', () => { + renderTitlebar({ + beforeTabs: , + afterTabs: , + }) - test('active tab connects to the window canvas', () => { - render() - expect(screen.getByTestId('chrome-tab-wegent')).toHaveClass('bg-black/[0.045]') - expect(screen.getByTestId('chrome-tab-wework')).not.toHaveClass('bg-black/[0.045]') - expect(screen.getByTestId('chrome-tab-wegent')).toHaveClass( - 'h-8', - 'min-w-24', - 'justify-center', - 'rounded-lg', - 'px-3', - 'text-center', - 'leading-none', - 'bg-black/[0.045]' - ) - expect(screen.getByTestId('chrome-tab-wegent')).not.toHaveClass( - 'border', - 'shadow-[0_1px_2px_rgba(0,0,0,0.06)]' - ) - expect(screen.getByTestId('chrome-titlebar')).toHaveClass('h-[38px]', 'bg-surface') - expect(screen.getByTestId('titlebar-right-workspace-zone')).toHaveClass( - 'top-0', - 'h-full', - 'items-center' - ) + expect(screen.getByTestId('chrome-titlebar')).toHaveClass('h-[38px]') + expect(screen.getByTestId('workspace-tab-strip')).toHaveTextContent('任务') + expect(screen.getByTestId('chrome-titlebar-before-tabs')).toHaveTextContent('Toggle sidebar') + expect(screen.getByTestId('chrome-titlebar-after-tabs')).toHaveTextContent('Update') expect(screen.getByTestId('titlebar-center')).toHaveClass('h-full', 'flex-1') expect(screen.getByTestId('titlebar-actions')).toHaveClass('h-full', 'gap-1', 'pr-3') }) - test('renders app views as always-visible top navigation tabs', () => { - render() - - const weworkTab = screen.getByTestId('chrome-tab-wework') - expect(screen.getByTestId('desktop-app-switcher')).toHaveTextContent('任务') - expect(weworkTab).toHaveAttribute('aria-current', 'page') - expect(weworkTab).not.toHaveAttribute('aria-haspopup') - expect(screen.queryByTestId('chrome-tab-todo')).not.toBeInTheDocument() - expect(screen.queryByTestId('chrome-tab-apps')).not.toBeInTheDocument() - }) - - test('renders after-tabs content between tabs and titlebar actions', () => { - render( - Update} - /> - ) - - const afterTabs = screen.getByTestId('chrome-titlebar-after-tabs') - const activeTab = screen.getByTestId('chrome-tab-wework') - const titlebarActions = screen.getByTestId('titlebar-actions') - expect(afterTabs).toHaveTextContent('Update') - expect( - activeTab.compareDocumentPosition(afterTabs) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - expect( - afterTabs.compareDocumentPosition(titlebarActions) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - }) - - test('renders before-tabs content between traffic lights and tabs', () => { + test('shows the macOS traffic-light spacer and starts native dragging', async () => { mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') enableTauri() - render( - Toggle sidebar} - /> - ) + renderTitlebar() const spacer = screen.getByTestId('macos-traffic-light-spacer') - const beforeTabs = screen.getByTestId('chrome-titlebar-before-tabs') - const activeTab = screen.getByTestId('chrome-tab-wework') - const toggleButton = screen.getByRole('button', { name: 'Toggle sidebar' }) - expect(beforeTabs).toHaveTextContent('Toggle sidebar') - expect(screen.getByTestId('chrome-titlebar')).not.toHaveAttribute('data-tauri-drag-region') - expect(toggleButton.closest('[data-tauri-drag-region]')).toBeNull() - expect(activeTab.closest('[data-tauri-drag-region]')).toBeNull() - expect( - spacer.compareDocumentPosition(beforeTabs) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - expect( - beforeTabs.compareDocumentPosition(activeTab) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - disableTauri() - }) - - test('calls onNavigate on tab click', async () => { - const fn = vi.fn() - render() - await userEvent.click(screen.getByTestId('chrome-tab-wegent')) - expect(fn).toHaveBeenCalledWith('wegent') - }) + expect(spacer).toHaveClass('w-[92px]', 'self-stretch') + expect(spacer.parentElement?.firstChild).toBe(spacer) - test('shows macOS traffic light spacer in Tauri runtime on Mac', () => { - mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') - enableTauri() - render() - const dragRegion = screen.getByTestId('macos-traffic-light-spacer') - expect(dragRegion.className).toContain('w-[92px]') - expect(dragRegion).toHaveClass('self-stretch') - expect(within(dragRegion).getByTestId('macos-titlebar-drag-region')).toHaveAttribute( - 'data-tauri-drag-region' + const nativeDragRegion = within(screen.getByTestId('titlebar-center')).getByTestId( + 'macos-titlebar-drag-region' ) - // macOS spacer is first child (left side) - expect(dragRegion?.parentElement?.firstChild).toBe(dragRegion) - disableTauri() - }) - - test('starts native dragging from the Wegent titlebar window drag region', async () => { - mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') - enableTauri() - render() - - const dragRegion = screen.getByTestId('titlebar-center') - expect(dragRegion).toHaveClass('h-full', 'flex-1') - const nativeDragRegion = within(dragRegion).getByTestId('macos-titlebar-drag-region') - expect(nativeDragRegion).toHaveClass('pointer-events-auto') - fireEvent.mouseDown(nativeDragRegion, { - button: 0, - }) - + fireEvent.mouseDown(nativeDragRegion, { button: 0 }) await waitFor(() => expect(startDragging).toHaveBeenCalledTimes(1)) - disableTauri() }) - test('starts native dragging from the empty right workspace titlebar area', async () => { - mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') - enableTauri() - render() - - const rightPanelDragRegion = screen.getByTestId('titlebar-right-panel-drag-region') - expect(screen.getByTestId('titlebar-right-panel')).toContainElement(rightPanelDragRegion) - fireEvent.mouseDown(within(rightPanelDragRegion).getByTestId('macos-titlebar-drag-region'), { - button: 0, - }) - - await waitFor(() => expect(startDragging).toHaveBeenCalledTimes(1)) - disableTauri() - }) - - test('shows custom window frame controls in Tauri runtime on Windows', () => { + test('shows custom window controls on Windows', () => { mockUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') enableTauri() - render() + renderTitlebar() + expect(screen.getByTestId('window-frame-controls')).toBeInTheDocument() - expect(screen.queryByTestId('desktop-window-controls')).not.toBeInTheDocument() expect(screen.queryByTestId('macos-traffic-light-spacer')).not.toBeInTheDocument() expect(screen.queryByTestId('topnav-feedback-button')).not.toBeInTheDocument() - disableTauri() }) - test('shows feedback button in Tauri runtime on macOS', () => { - mockUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') - enableTauri() - render() - expect(screen.getByTestId('topnav-feedback-button')).toBeInTheDocument() - expect(screen.queryByTestId('window-frame-controls')).not.toBeInTheDocument() - disableTauri() - }) + test('can hide workbench portals without removing the document tabs', () => { + renderTitlebar({ showWorkspacePortals: false, showFeedback: false }) - test('no spacer when not in Tauri runtime', () => { - render() - expect(document.querySelector('[data-tauri-drag-region]')).not.toBeInTheDocument() + expect(screen.getByTestId('workspace-tab-strip')).toBeInTheDocument() + expect(screen.queryByTestId('titlebar-actions')).not.toBeInTheDocument() + expect(screen.queryByTestId('titlebar-right-panel')).not.toBeInTheDocument() }) }) diff --git a/wework/src/components/topnav/ChromeTitlebar.tsx b/wework/src/components/topnav/ChromeTitlebar.tsx index 1761dd90a7..55ed349f2e 100644 --- a/wework/src/components/topnav/ChromeTitlebar.tsx +++ b/wework/src/components/topnav/ChromeTitlebar.tsx @@ -1,7 +1,6 @@ import { cn } from '@/lib/utils' import { isTauriRuntime } from '@/lib/runtime-environment' import { getPlatform } from '@/lib/platform' -import type { AppTab } from '@/config/apps' import { TITLEBAR_ACTIONS_PORTAL_ID, TITLEBAR_CENTER_PORTAL_ID, @@ -9,34 +8,26 @@ import { } from './TitlebarActionsPortal' import { TitlebarExtensionSlot } from '@extensions/titlebar' import { MacOSTitleBarDragRegion } from '@/components/layout/MacOSTitleBarDragRegion' -import { DesktopAppSwitcher } from '@/components/layout/DesktopAppSwitcher' import { WindowFrameControls } from '@/components/layout/WindowFrameControls' import { TaskFeedbackDialog } from '@/features/feedback/TaskFeedbackDialog' import { useTranslation } from '@/hooks/useTranslation' import { MessageSquareWarning } from 'lucide-react' import { DESKTOP_TOP_BAR_BUTTON_CLASS } from '@/components/layout/DesktopTopBar' import { useState, type ReactNode } from 'react' +import { WorkspaceTabStrip } from '@/features/workspace-tabs/WorkspaceTabStrip' interface ChromeTitlebarProps { - tabs: AppTab[] - activeKey: string - onNavigate: (appKey: string) => void beforeTabs?: ReactNode afterTabs?: ReactNode className?: string - iconOnlyTabs?: boolean showWorkspacePortals?: boolean showFeedback?: boolean } export function ChromeTitlebar({ - tabs, - activeKey, - onNavigate, beforeTabs, afterTabs, className, - iconOnlyTabs = false, showWorkspacePortals = true, showFeedback = true, }: ChromeTitlebarProps) { @@ -47,8 +38,7 @@ export function ChromeTitlebar({
@@ -69,43 +59,7 @@ export function ChromeTitlebar({
)} - {/* Tab strip */} - {iconOnlyTabs ? ( - - ) : ( -
- {tabs.map(tab => ( - - ))} -
- )} + {afterTabs && (
{afterTabs} diff --git a/wework/src/e2e/automation.ts b/wework/src/e2e/automation.ts index 59e880081a..b876fa7b42 100644 --- a/wework/src/e2e/automation.ts +++ b/wework/src/e2e/automation.ts @@ -717,11 +717,21 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro const getWindowFocusSnapshot = async () => { const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') const popoutWindow = await WebviewWindow.getByLabel('popout-window') + const workspaceWindows = (await WebviewWindow.getAll()).filter(window => + window.label.startsWith('workspace-') + ) return JSON.stringify({ mainFocused: await getCurrentWindow().isFocused(), popoutExists: Boolean(popoutWindow), popoutFocused: popoutWindow ? await popoutWindow.isFocused() : false, popoutVisible: popoutWindow ? await popoutWindow.isVisible() : false, + workspaceWindows: await Promise.all( + workspaceWindows.map(async window => ({ + label: window.label, + focused: await window.isFocused(), + visible: await window.isVisible(), + })) + ), }) } @@ -735,6 +745,8 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro return captureDesktopControlScreenshot(command.selector) case 'capturePopoutWindow': return invoke('capture_popout_webview') + case 'captureWorkspaceWindow': + return invoke('capture_workspace_webview') case 'closeMainWindowToTray': return '' case 'requestMainWindowClose': diff --git a/wework/src/features/todo/CloudTodoWorkspace.test.tsx b/wework/src/features/todo/CloudTodoWorkspace.test.tsx index 663fbea81b..48c34100b2 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.test.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.test.tsx @@ -201,6 +201,28 @@ describe('CloudTodoWorkspace', () => { localStorage.clear() }) + it('reports the concrete project name for the active document tab', async () => { + const onActiveProjectChange = vi.fn() + + render( + + ) + + await userEvent.click(await screen.findByTestId('cloud-sidebar-project-11')) + expect(onActiveProjectChange).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 11, name: 'Wegent V4' }) + ) + + await userEvent.click(screen.getByRole('button', { name: '项目空间' })) + expect(onActiveProjectChange).toHaveBeenLastCalledWith(null) + }) + it('renames and archives a project from the sidebar menu', async () => { const workbenchServices = services() @@ -810,11 +832,14 @@ describe('CloudTodoWorkspace', () => { it('creates a project space without requesting a project key', async () => { const workbenchServices = services() + const onActiveProjectChange = vi.fn() render( ) @@ -831,6 +856,9 @@ describe('CloudTodoWorkspace', () => { visibility: 'private', }) ) + expect(onActiveProjectChange).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 12, name: 'Wegent Test', location: 'cloud' }) + ) expect(screen.queryByTestId('cloud-project-name')).not.toBeInTheDocument() }) diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index b75c273be1..822507867a 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -211,6 +211,8 @@ interface CloudTodoWorkspaceProps { user: UserProfile localProjects: ProjectWithTasks[] services: WorkbenchServices + activeProjectId?: string | null + onActiveProjectChange?: (project: LocatedCloudProject | null) => void } const columnEmptyHints: Record = { @@ -654,7 +656,13 @@ function ProjectDialog({ ) } -export function CloudTodoWorkspace({ user, localProjects, services }: CloudTodoWorkspaceProps) { +export function CloudTodoWorkspace({ + user, + localProjects, + services, + activeProjectId, + onActiveProjectChange, +}: CloudTodoWorkspaceProps) { const { t } = useTranslation('common') const projectSpaceApis = useMemo(() => { if (services.projectSpaceApis) return services.projectSpaceApis @@ -677,7 +685,9 @@ export function CloudTodoWorkspace({ user, localProjects, services }: CloudTodoW // Every project's loop items, cached for the projects-home overview // (stats, recent activity). Keyed by project id. const [projectItems, setProjectItems] = useState>({}) - const [selectedProjectId, setSelectedProjectId] = useState(null) + const [internalSelectedProjectId, setSelectedProjectId] = useState(null) + const selectedProjectId = + activeProjectId === undefined ? internalSelectedProjectId : activeProjectId const [items, setItems] = useState([]) // Which project's items are currently in `items`. Anything else rendered on // the board would be stale, so the board shows the skeleton instead. @@ -1060,7 +1070,7 @@ export function CloudTodoWorkspace({ user, localProjects, services }: CloudTodoW breadcrumbItem = items.find(candidate => candidate.id === breadcrumbItem?.parent_id) ?? null } - function selectProject(projectId: string | null) { + function applyProjectSelection(projectId: string | null) { setSelectedProjectId(projectId) setProjectView('board') setBoardParentId(null) @@ -1071,6 +1081,13 @@ export function CloudTodoWorkspace({ user, localProjects, services }: CloudTodoW setProjectSearchFilters(emptyTaskSearchFilters) } + function selectProject(projectId: string | null) { + applyProjectSelection(projectId) + onActiveProjectChange?.( + projectId === null ? null : (projects.find(project => project.id === projectId) ?? null) + ) + } + async function renameSelectedProject() { if (!renameProject) return const api = apiForProjectId(renameProject.id) @@ -2275,7 +2292,8 @@ export function CloudTodoWorkspace({ user, localProjects, services }: CloudTodoW setProjectMembers(current => ({ ...current, [project.id]: members })) ) } - selectProject(project.id) + applyProjectSelection(project.id) + onActiveProjectChange?.(locatedProject) setCreateProjectOpen(false) }} /> diff --git a/wework/src/features/workspace-tabs/WorkspaceTabStrip.test.tsx b/wework/src/features/workspace-tabs/WorkspaceTabStrip.test.tsx new file mode 100644 index 0000000000..887582ab8d --- /dev/null +++ b/wework/src/features/workspace-tabs/WorkspaceTabStrip.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { WorkspaceTabsProvider } from './WorkspaceTabsContext' +import { WorkspaceTabStrip } from './WorkspaceTabStrip' + +const openWorkspaceTabWindow = vi.fn().mockResolvedValue(undefined) + +vi.mock('./workspaceWindow', () => ({ + openWorkspaceTabWindow: (tab: unknown) => openWorkspaceTabWindow(tab), +})) + +const labels = { + task: '任务', + board: '项目空间', + agent: '智能体', + auxiliary: '工作区', +} + +function renderStrip(search = '') { + return render( + + + + ) +} + +describe('WorkspaceTabStrip', () => { + beforeEach(() => { + localStorage.clear() + openWorkspaceTabWindow.mockClear() + window.history.replaceState({}, '', '/') + }) + + test('opens project spaces as a real tab and switches between tabs', async () => { + const user = userEvent.setup() + renderStrip() + + await user.click(screen.getByTestId('workspace-tab-add')) + await user.click(screen.getByTestId('workspace-tab-add-board')) + + const tablist = screen.getByTestId('workspace-tab-strip') + expect(within(tablist).getAllByRole('tab')).toHaveLength(2) + expect(within(tablist).getByText('项目空间').closest('[role="tab"]')).toHaveAttribute( + 'aria-selected', + 'true' + ) + + await user.click(within(tablist).getByText('任务')) + expect(within(tablist).getByText('任务').closest('[role="tab"]')).toHaveAttribute( + 'aria-selected', + 'true' + ) + }) + + test('closes and restores the active tab with browser shortcuts', async () => { + const user = userEvent.setup() + renderStrip() + + await user.click(screen.getByTestId('workspace-tab-add')) + await user.click(screen.getByTestId('workspace-tab-add-board')) + fireEvent.keyDown(window, { key: 'w', metaKey: true }) + expect(screen.queryByText('项目空间')).not.toBeInTheDocument() + + fireEvent.keyDown(window, { key: 't', metaKey: true, shiftKey: true }) + await waitFor(() => expect(screen.getByText('项目空间')).toBeInTheDocument()) + }) + + test('opens a tab in a new window from its context menu', async () => { + const user = userEvent.setup() + renderStrip() + + fireEvent.contextMenu(screen.getByText('任务').closest('[role="tab"]')!) + await user.click(screen.getByTestId('workspace-tab-open-new-window')) + + expect(openWorkspaceTabWindow).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'task', title: '任务' }) + ) + }) + + test('opens the tab context menu from the keyboard', () => { + renderStrip() + + fireEvent.keyDown(screen.getByText('任务').closest('button')!, { + key: 'F10', + shiftKey: true, + }) + + expect(screen.getByTestId('workspace-tab-context-menu')).toBeVisible() + }) +}) diff --git a/wework/src/features/workspace-tabs/WorkspaceTabStrip.tsx b/wework/src/features/workspace-tabs/WorkspaceTabStrip.tsx new file mode 100644 index 0000000000..3bbb1c0e3f --- /dev/null +++ b/wework/src/features/workspace-tabs/WorkspaceTabStrip.tsx @@ -0,0 +1,295 @@ +import { Bot, CheckSquare2, Columns3, Plus, X } from 'lucide-react' +import { useEffect, useRef, useState, type DragEvent, type RefObject } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from '@/hooks/useTranslation' +import { cn } from '@/lib/utils' +import { openWorkspaceTabWindow } from './workspaceWindow' +import { useWorkspaceTabs } from './workspaceTabsContextValue' +import type { WorkspaceTab, WorkspaceTabKind } from './workspaceTabs' + +interface MenuPosition { + left: number + top: number +} + +interface TabContextMenuState extends MenuPosition { + tabId: string +} + +function tabKindIcon(kind: WorkspaceTabKind) { + if (kind === 'board') { + return