diff --git a/docs/en/wework/browser.md b/docs/en/wework/browser.md index e0a00ffb772..d3a15ba6be6 100644 --- a/docs/en/wework/browser.md +++ b/docs/en/wework/browser.md @@ -8,6 +8,8 @@ The built-in browser opens in the right workspace so you can inspect websites, v Open the new-tab menu, choose **Browser**, and enter an `http`, `https`, or `file://` address, or a local absolute path. The current page remains loaded when you switch to file or review tabs. `file://` addresses and paths such as `/Users/.../file.html` are useful for previewing local HTML, PDF, image, and text files, and they can also open local folders. Markdown and extensionless text files also render as text. Folders render as read-only index pages where you can enter child folders or open files. When a local file cannot be previewed, Wework cancels the local-file download and shows a short toast. +You can keep multiple task tabs open at the top, and each task owns an independent built-in browser page. After you switch to another task, the previous task's browser keeps running while hidden. AI in that task can continue opening, waiting for, and inspecting pages, and the latest page state appears when you switch back. Browser pages are not shared across tasks. The right workspace currently provides one browser tab per task; it does not provide a browser-internal tab strip. + Background page-state synchronization does not overwrite the address while you are editing it. If a page is already open, leaving the address field without submitting restores the current page URL. If no page is open yet, the unsubmitted address is preserved and restored after switching conversations. If a Wework dialog, menu, or overlay intersects the right browser area, the browser temporarily hides so the native page cannot cover the main interface. It returns automatically when the overlay closes or moves away. diff --git a/docs/en/wework/developer-guide/wework-embedded-browser.md b/docs/en/wework/developer-guide/wework-embedded-browser.md index 01964b52e12..bc8d41a21bd 100644 --- a/docs/en/wework/developer-guide/wework-embedded-browser.md +++ b/docs/en/wework/developer-guide/wework-embedded-browser.md @@ -55,11 +55,14 @@ Browser instances are bound by pane/task label: - A new conversation without a runtime task uses the current pane key as a temporary browser label. - After sending from a new conversation creates a runtime task, Wework relabels the temporary browser to the new task label. - When the user switches tasks, only the browser bound to the active pane/task is visible; pages from other tasks must not leak across panes. -- MCP open requests initially use the default label. When the current pane becomes inactive, Wework moves its WebView to a task-specific label, and only the active task may claim the default label. +- Executor injects a task-specific label for an existing runtime task. Only temporary new conversations use the default or pane label, and only the active task may claim the default label. +- When a top-level task tab becomes inactive, its workbench effects must stay active so the task-specific bridge listener can handle background `open`, `waitFor`, and `inspect` requests. Hide the React surface with `hidden` and keep the native WebView invisible; non-task tabs should not pay this keep-alive cost. - When the right browser panel is closed, the native WebView is hidden offscreen and must not cover the chat area, debug panel, or splitter. This binding keeps the browser the user sees and the browser the agent controls as the same object. +Each pane/task label currently maps to one browser host. Wework can keep multiple top-level task tabs and their independent browsers alive, but there is no browser-internal multi-tab model inside one task. Adding browser-internal tabs must extend bridge routing identity, lifecycle ownership, and the Agent tool protocol instead of reusing another task's host. + A programmatic first open must treat "the panel was requested" and "the native WebView is ready for navigation" as separate phases. The bridge stores pending navigation with a request ID and executes it only after the browser host for that task has nonzero visible bounds and reaches the ready state. If the frontend listener registers after the request, it recovers the event from the pending-request snapshot. The tool may report success only after the native WebView has navigated to the requested URL, not merely because the right panel or a blank WebView was created. Close requests triggered by task switching or component unmounting must include the expected native label. A stale pane may close only the WebView it created; it must not close an instance that was relabeled or adopted by a replacement pane. An inactive task may prepare a hidden ready browser, but it cannot display that browser over the active task. @@ -113,7 +116,7 @@ cargo test --manifest-path wework/src-tauri/Cargo.toml embedded_browser pnpm --filter wework e2e:desktop:embedded-browser ``` -`e2e:desktop:embedded-browser` must issue the first bridge `open` from a newly created local task and verify that it completes before the tool timeout, creates exactly one visible native host, and preserves the requested URL. A test that covers only a second open or manually exposes the browser panel first does not exercise the first-open race. +`e2e:desktop:embedded-browser` must create task A, switch to task B, and then use task A's specific label for the first bridge `open`, `waitFor`, and `inspect`. It verifies that the inactive task completes background navigation before the tool timeout without taking over task B's browser, and that the same page state is visible after switching back to task A. A test that covers only a second open, an active-task open, or a manually exposed browser panel does not exercise the first-open and inactive-task routing races. When the browser change touches Tauri commands, native WebView behavior, IPC, or the Agent action path, also start an isolated real Tauri session with `pnpm --filter wework ai:verify start` and record evidence for opening a page, inspect, actions, and screenshot. If the full E2E is too slow locally, document why it was not run and make sure CI runs `e2e:desktop:embedded-browser`. diff --git a/docs/zh/wework/browser.md b/docs/zh/wework/browser.md index cf3773013c1..0e86c31495c 100644 --- a/docs/zh/wework/browser.md +++ b/docs/zh/wework/browser.md @@ -14,6 +14,8 @@ Wework 内置浏览器位于右侧工作区,可在处理任务时查看网页 浏览器会保留当前地址、标题和页面状态。切换到文件或审核标签页不会关闭网页。右侧工作区一次保留一个浏览器标签页。`file://` 地址和 `/Users/.../file.html` 这类本机绝对路径适合预览本机 HTML、PDF、图片和文本文件,也可以打开本机目录。Markdown、无扩展名的文本文件也会按文本预览。目录会显示为只读索引页,点击子目录可继续浏览,点击文件会进入对应预览;如果文件类型无法预览,Wework 会取消本地文件下载并弹出一个短暂提示。 +顶部可以同时打开多个任务标签页,每个任务都拥有独立的内置浏览器页面。切换到其它任务后,原任务的浏览器会保持隐藏运行;该任务中的 AI 仍可继续打开、等待和检查页面,切回任务时会显示最新页面状态。浏览器页面不会跨任务共享。目前一个任务的右侧工作区仍只提供一个浏览器标签页,不提供浏览器内部的多标签栏。 + 编辑地址栏时,后台页面状态同步不会覆盖正在输入的地址。如果浏览器已经打开网页,未提交直接离开地址栏时会恢复当前页面的真实地址;如果尚未打开网页,未提交的地址会保留,并在切换会话后恢复。 Wework 弹窗、菜单或浮层与右侧浏览器区域重叠时,浏览器会暂时隐藏,避免原生网页遮挡主界面操作。关闭或移开浮层后,浏览器会自动恢复。 diff --git a/docs/zh/wework/developer-guide/wework-embedded-browser.md b/docs/zh/wework/developer-guide/wework-embedded-browser.md index 65321af9890..379fc8dce61 100644 --- a/docs/zh/wework/developer-guide/wework-embedded-browser.md +++ b/docs/zh/wework/developer-guide/wework-embedded-browser.md @@ -55,11 +55,14 @@ Agent 面向模型暴露的是浏览器动作工具,而不是底层 WebKit API - 未创建运行任务的新对话使用当前 pane key 生成临时浏览器 label。 - 新对话发送后如果创建了 runtime task,Wework 会把临时浏览器 relabel 到新 task label。 - 切换任务时,只显示当前 pane/task 绑定的浏览器;其它任务的页面不会跨 pane 泄漏。 -- MCP 打开请求先使用默认 label;当前 pane 失活时,Wework 会把 WebView 迁移到任务专属 label,并且只有活跃任务可以接管默认 label。 +- Executor 为已有 runtime task 注入任务专属 label;临时新对话才使用默认或 pane label,并且只有活跃任务可以接管默认 label。 +- 顶层任务标签页失活时必须继续保留 workbench effect,使任务专属 bridge listener 可以处理后台 `open`、`waitFor` 和 `inspect`。React 表面通过 `hidden` 隐藏,原生 WebView 保持不可见;不要让非任务标签页承担这项保活成本。 - 浏览器右侧面板关闭时,原生 WebView 会被隐藏到不可见区域,不应覆盖聊天区、debug panel 或分割线。 这种绑定保证“用户看到的浏览器”和“agent 控制的浏览器”是同一个对象。 +当前每个 pane/task label 对应一个浏览器宿主。Wework 顶层可以同时保留多个任务标签页及其独立浏览器,但单个任务内部没有浏览器多标签模型;如果新增浏览器内部多标签,必须同时扩展 bridge 路由标识、生命周期和 Agent 工具协议,不能复用另一个任务的宿主模拟。 + 程序化首次打开必须把“面板已请求打开”和“原生 WebView 已可导航”视为两个不同阶段。bridge 应保存带 request ID 的待处理导航,等任务对应的浏览器宿主获得非零可见尺寸并进入 ready 状态后再执行;监听器晚于请求注册时,前端必须通过待处理请求快照恢复事件。只有原生 WebView 已导航到请求地址后才能向工具返回成功,不能仅因右侧面板或空白 WebView 已创建就报告成功。 任务切换或组件卸载时,关闭请求必须携带预期的原生 label。过期 pane 只能关闭自己创建的 WebView,不能关闭已经迁移或由新 pane 接管的实例。非活跃任务可以提前创建隐藏且 ready 的浏览器,但不能显示在当前任务上方。 @@ -113,7 +116,7 @@ cargo test --manifest-path wework/src-tauri/Cargo.toml embedded_browser pnpm --filter wework e2e:desktop:embedded-browser ``` -`e2e:desktop:embedded-browser` 必须从新建本地任务执行第一次 bridge `open`,验证它在工具超时前完成、只产生一个可见原生宿主,并保留请求 URL。仅验证第二次打开或手工先展开浏览器面板不能覆盖首次打开竞态。 +`e2e:desktop:embedded-browser` 必须在任务 A 创建后切换到任务 B,再使用任务 A 的专属 label 执行第一次 bridge `open`、`waitFor` 和 `inspect`,验证非活跃任务能在工具超时前完成后台导航且不会接管任务 B 的浏览器。切回任务 A 后还要验证同一页面状态可见。仅验证第二次打开、活跃任务打开或手工先展开浏览器面板,不能覆盖首次打开和非活跃任务路由竞态。 浏览器涉及 Tauri 命令、原生 WebView、IPC 或 Agent 操作链路时,还应使用 `pnpm --filter wework ai:verify start` 启动隔离真实 Tauri 会话,并记录打开页面、inspect、动作和截图的验证证据。完整 E2E 太慢时,合并前至少要说明未运行的原因,并确保 CI 中的 `e2e:desktop:embedded-browser` 会覆盖该场景。 diff --git a/wework/e2e/desktop/scenarios/embedded-browser-agent.scenario.mjs b/wework/e2e/desktop/scenarios/embedded-browser-agent.scenario.mjs index 6c2d988a503..565119a3b2c 100644 --- a/wework/e2e/desktop/scenarios/embedded-browser-agent.scenario.mjs +++ b/wework/e2e/desktop/scenarios/embedded-browser-agent.scenario.mjs @@ -37,7 +37,7 @@ const LOCAL_DIRECTORY_TEXT = 'local-directory-readme.txt' const BROWSER_DATA_COOKIE_PATH = '/embedded-browser-data-cookie-fixture' const BROWSER_DATA_CACHE_PATH = '/embedded-browser-data-cache-fixture' const BROWSER_DATA_CACHE_RESOURCE_PATH = '/embedded-browser-data-cache-resource.js' -const BROWSER_MORE_BUTTON_SELECTOR = '[data-testid="workspace-browser-more-button"]' +const BROWSER_MORE_BUTTON_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="workspace-browser-more-button"]` const BROWSER_CLEAR_DATA_SELECTOR = '[data-testid="workspace-browser-clear-data-item"]' const BROWSER_CLEAR_COOKIES_SELECTOR = '[data-testid="workspace-browser-clear-cookies-item"]' const BROWSER_CLEAR_CACHE_SELECTOR = '[data-testid="workspace-browser-clear-cache-item"]' @@ -128,7 +128,11 @@ function browserDataCookieFixtureHtml() { Browser data Cookie fixture

Browser data Cookie fixture

- + ` } @@ -225,6 +229,28 @@ async function waitForVisibleSingleElement(control, selector, timeoutMs, message await new Promise(resolve => setTimeout(resolve, 50)) } const diagnostics = { + appShell: JSON.parse(await control.command('getElementMetrics', '[data-testid="app-shell"]')), + appShellHeight: await control.command('getInlineStyle', '[data-testid="app-shell"]', { + value: 'height', + }), + tauriViewportHeight: await control.command('getAttribute', '[data-testid="app-shell"]', { + value: 'data-tauri-viewport-height', + }), + routeHost: JSON.parse( + await control.command('getElementMetrics', '[data-testid="app-route-host"]') + ), + activeTaskSurface: JSON.parse( + await control.command( + 'getElementMetrics', + '[data-workspace-tab-content][aria-hidden="false"]' + ) + ), + desktopWorkbenchSurface: JSON.parse( + await control.command( + 'getElementMetrics', + '[data-workspace-tab-content][aria-hidden="false"] [data-testid="desktop-workbench-surface"]' + ) + ), activeWorkbench: JSON.parse( await control.command('getElementMetrics', ACTIVE_WORKBENCH_SELECTOR) ), @@ -449,30 +475,157 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) ) const bridgeIdentity = await waitForBridgeIdentity(executorHome, uiTimeoutMs) const bridgeCall = payload => callBridge(bridgeIdentity, { label: browserLabel, ...payload }) - const firstOpenStartedAt = Date.now() - const firstOpenPromise = bridgeCall({ - action: 'open', - url: fixtureUrl, - timeoutMs: 8_000, + const firstTaskTabTestId = await control.command( + 'getAttribute', + '[data-tab-kind="task"][aria-selected="true"]', + { value: 'data-testid' } + ) + assert.ok(firstTaskTabTestId, 'The browser task did not expose its workspace tab identity') + await control.command('click', '[data-testid="workspace-tab-add"]') + await control.command('click', '[data-testid="workspace-tab-add-task"]') + await control.command('waitFor', '[data-tab-kind="task"][aria-selected="true"]', { + timeoutMs: uiTimeoutMs, }) - const [firstOpenResult] = await Promise.all([ - firstOpenPromise, - waitForVisibleSingleElement( - control, - BROWSER_NATIVE_VIEW_SELECTOR, - 8_000, - 'The first bridge open did not make exactly one active host visible' - ), - ]) + const secondTaskTabTestId = await control.command( + 'getAttribute', + '[data-tab-kind="task"][aria-selected="true"]', + { value: 'data-testid' } + ) + assert.notEqual( + secondTaskTabTestId, + firstTaskTabTestId, + 'Opening another task tab did not make the browser-owning task inactive' + ) + const secondTaskTabId = secondTaskTabTestId.replace('workspace-tab-select-', '') + const activeBrowserLabel = await control.command( + 'getAttribute', + `[data-testid="workspace-tab-content-${secondTaskTabId}"] [data-embedded-browser-label]`, + { value: 'data-embedded-browser-label' } + ) + assert.ok(activeBrowserLabel, 'The active task did not expose a browser label') + assert.notEqual( + activeBrowserLabel, + browserLabel, + 'The second task tab reused the inactive task browser label' + ) + const firstOpenStartedAt = Date.now() + const firstOpenResult = await withTimeout( + bridgeCall({ + action: 'open', + url: fixtureUrl, + timeoutMs: 8_000, + }), + 8_000, + 'The inactive task did not bootstrap its task-scoped browser' + ) assert.equal( firstOpenResult.ok, true, - `The first bridge open from a fresh task failed: ${JSON.stringify(firstOpenResult)}` + `The first bridge open from an inactive task failed: ${JSON.stringify(firstOpenResult)}` ) assert.ok( Date.now() - firstOpenStartedAt < 8_000, 'The first bridge open only completed after its tool timeout' ) + const inactiveWait = await bridgeCall({ + action: 'waitFor', + options: { condition: { textVisible: READY_TEXT } }, + timeoutMs: 5_000, + }) + assert.equal( + inactiveWait.ok, + true, + `The inactive task browser did not finish loading: ${JSON.stringify(inactiveWait)}` + ) + const inactiveInspect = await bridgeCall({ + action: 'inspect', + options: { interactiveOnly: false, includeTextBlocks: true, maxNodes: 80 }, + timeoutMs: 5_000, + }) + assert.ok( + inactiveInspect.inspectText.includes(READY_TEXT), + 'The inactive task browser did not load the requested page' + ) + const selectedTaskAfterBackgroundCalls = await control.command( + 'getAttribute', + '[data-tab-kind="task"][aria-selected="true"]', + { value: 'data-testid' } + ) + assert.equal( + selectedTaskAfterBackgroundCalls, + secondTaskTabTestId, + 'Inactive task browser calls changed the selected task tab' + ) + const activeLabelAfterBackgroundCalls = await control.command( + 'getAttribute', + `[data-testid="workspace-tab-content-${secondTaskTabId}"] [data-embedded-browser-label]`, + { value: 'data-embedded-browser-label' } + ) + assert.equal( + activeLabelAfterBackgroundCalls, + activeBrowserLabel, + 'Inactive task browser calls changed the active task browser host' + ) + const inactiveTaskSurfaceSelector = `[data-testid="workspace-tab-content-${firstTaskTabTestId.replace( + 'workspace-tab-select-', + '' + )}"]` + const inactiveTaskAriaHidden = await control.command( + 'getAttribute', + inactiveTaskSurfaceSelector, + { value: 'aria-hidden' } + ) + assert.equal( + inactiveTaskAriaHidden, + 'true', + 'Inactive task browser calls exposed the task surface to accessibility' + ) + const inactiveTaskClassName = await control.command( + 'getAttribute', + inactiveTaskSurfaceSelector, + { value: 'class' } + ) + assert.match( + inactiveTaskClassName, + /(?:^|\s)invisible(?:\s|$)/, + 'Inactive task browser calls made the task surface visible' + ) + await control.command('click', `[data-testid="${firstTaskTabTestId}"]`) + await control.command( + 'waitFor', + `[data-testid="${firstTaskTabTestId}"][aria-selected="true"]`, + { timeoutMs: uiTimeoutMs } + ) + await control.command( + 'click', + `[data-testid="${secondTaskTabTestId.replace('workspace-tab-select-', 'workspace-tab-close-')}"]` + ) + const closedTaskContentCount = await control.command( + 'getElementCount', + `[data-testid="workspace-tab-content-${secondTaskTabId}"]` + ) + assert.equal( + closedTaskContentCount, + '0', + 'Closing the second task left its workbench mounted' + ) + await control.command( + 'waitFor', + `[data-testid="workspace-tab-content-${firstTaskTabTestId.replace( + 'workspace-tab-select-', + '' + )}"][aria-hidden="false"]`, + { timeoutMs: uiTimeoutMs } + ) + const browserMenuButtonCount = await control.command( + 'getElementCount', + BROWSER_MORE_BUTTON_SELECTOR + ) + assert.equal( + browserMenuButtonCount, + '1', + 'The active task did not retain a unique browser action menu' + ) await control.command('waitFor', BROWSER_INPUT_SELECTOR, { timeoutMs: uiTimeoutMs }) await waitForControlValue( control, @@ -483,11 +636,11 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) ) await control.command('waitFor', BROWSER_NATIVE_VIEW_SELECTOR, { timeoutMs: uiTimeoutMs }) await control.command('finishAnimations', 'body') - const browserPanelMetrics = JSON.parse( - await control.command( - 'getElementMetrics', - `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="workspace-browser-panel"]` - ) + const browserPanelMetrics = await waitForVisibleSingleElement( + control, + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="workspace-browser-panel"]`, + uiTimeoutMs, + 'Browser panel did not become visible' ) const [browserPanelWidth, rightPanelShellWidth] = await Promise.all([ control.command( @@ -501,11 +654,10 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) { value: 'width' } ), ]) - assert.equal(browserPanelMetrics.length, 1, 'Expected exactly one active browser panel') assert.ok( - browserPanelMetrics[0].width > 1 && browserPanelMetrics[0].height > 1, + browserPanelMetrics.width > 1 && browserPanelMetrics.height > 1, `Browser panel is not visible: ${JSON.stringify({ - metrics: browserPanelMetrics[0], + metrics: browserPanelMetrics, browserPanelWidth, rightPanelShellWidth, })}` @@ -582,7 +734,8 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) await control.command('click', BROWSER_AGENT_RESUME_SELECTOR) await pendingAgentWait - const cookieFixtureUrl = `${control.url}${BROWSER_DATA_COOKIE_PATH}` + const cookieVerificationUrl = `${control.url}${BROWSER_DATA_COOKIE_PATH}` + const cookieFixtureUrl = `${cookieVerificationUrl}?set=1` await bridgeCall({ action: 'open', url: cookieFixtureUrl, timeoutMs: 8_000 }) await bridgeCall({ action: 'waitFor', @@ -601,7 +754,15 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) ) assert.equal(cookieBeforeClear.value, true, 'Cookie fixture did not set its test cookie') await control.command('click', BROWSER_MORE_BUTTON_SELECTOR) + await control.command('waitFor', BROWSER_CLEAR_DATA_SELECTOR, { + enabled: true, + timeoutMs: uiTimeoutMs, + }) await control.command('click', BROWSER_CLEAR_DATA_SELECTOR) + await control.command('waitFor', BROWSER_CLEAR_COOKIES_SELECTOR, { + enabled: true, + timeoutMs: uiTimeoutMs, + }) await control.command('click', BROWSER_CLEAR_COOKIES_SELECTOR) await control.command('waitFor', TRANSIENT_NOTICE_SELECTOR, { text: BROWSER_CLEAR_STARTED_TEXT, @@ -609,7 +770,13 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) }) await control.command('waitFor', TRANSIENT_NOTICE_SELECTOR, { text: BROWSER_CLEAR_COMPLETED_TEXT, - timeoutMs: uiTimeoutMs, + timeoutMs: 35_000, + }) + await bridgeCall({ action: 'open', url: cookieVerificationUrl, timeoutMs: 8_000 }) + await bridgeCall({ + action: 'waitFor', + options: { condition: { textVisible: 'Browser data Cookie fixture' } }, + timeoutMs: 5_000, }) const cookieAfterClear = await bridgeCall({ action: 'evaluate', @@ -632,12 +799,24 @@ export function createDesktopScenario({ executorHome, resultDir, uiTimeoutMs }) }) assert.equal(cacheResourceRequests, 1, 'Cache fixture did not request its resource once') await control.command('click', BROWSER_MORE_BUTTON_SELECTOR) + await control.command('waitFor', BROWSER_CLEAR_DATA_SELECTOR, { + enabled: true, + timeoutMs: uiTimeoutMs, + }) await control.command('click', BROWSER_CLEAR_DATA_SELECTOR) + await control.command('waitFor', BROWSER_CLEAR_CACHE_SELECTOR, { + enabled: true, + timeoutMs: uiTimeoutMs, + }) await control.command('click', BROWSER_CLEAR_CACHE_SELECTOR) await control.command('waitFor', TRANSIENT_NOTICE_SELECTOR, { - text: BROWSER_CLEAR_COMPLETED_TEXT, + text: BROWSER_CLEAR_STARTED_TEXT, timeoutMs: uiTimeoutMs, }) + await control.command('waitFor', TRANSIENT_NOTICE_SELECTOR, { + text: BROWSER_CLEAR_COMPLETED_TEXT, + timeoutMs: 35_000, + }) await bridgeCall({ action: 'close', timeoutMs: 8_000 }) await control.command('fill', BROWSER_INPUT_SELECTOR, { value: cacheFixtureUrl }) await control.command('submit', BROWSER_INPUT_SELECTOR) diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index 6b5e6a7cfda..08d1321b7f9 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -4012,9 +4012,15 @@ async function configureDefaultProjectSpaceAssociation(control, localProjectId) value: 'Task Follow-up Board', }) await control.command('clickWhenEnabled', '[data-testid="save-local-project-button"]') + await waitForSnapshot( + control, + snapshot => !snapshot.testIds.includes('local-project-edit-dialog'), + 'Saving the local project did not close the edit dialog', + WORKBENCH_READY_TIMEOUT_MS + ) await control.command('waitFor', '[data-testid="project-space-context-pill"]', { text: '加入看板 · Task Follow-up Board', - timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, }) await control.command('click', '[data-testid="add-context-button"]') await control.command('waitFor', '[data-testid="add-project-space-context-button"]', { diff --git a/wework/src-tauri/src/embedded_browser/data_clearing.rs b/wework/src-tauri/src/embedded_browser/data_clearing.rs index c7c6a68534e..79d9ea55afa 100644 --- a/wework/src-tauri/src/embedded_browser/data_clearing.rs +++ b/wework/src-tauri/src/embedded_browser/data_clearing.rs @@ -168,11 +168,7 @@ async fn clear_platform_webview_data( ) -> Result<(), String> { use block2::RcBlock; use objc2_foundation::{NSDate, NSSet, NSString, NSURLCache}; - use objc2_web_kit::{ - WKWebView, WKWebsiteDataTypeCookies, WKWebsiteDataTypeDiskCache, - WKWebsiteDataTypeFetchCache, WKWebsiteDataTypeMemoryCache, - WKWebsiteDataTypeOfflineWebApplicationCache, - }; + use objc2_web_kit::{WKWebView, WKWebsiteDataTypeCookies, WKWebsiteDataTypeDiskCache}; let (sender, receiver) = oneshot::channel(); let sender = Arc::new(Mutex::new(Some(sender))); @@ -187,15 +183,12 @@ async fn clear_platform_webview_data( ); return; }; - let mut data_types: Vec<&NSString> = Vec::with_capacity(5); + let mut data_types: Vec<&NSString> = Vec::with_capacity(2); if data_kinds.cookies { data_types.push(unsafe { WKWebsiteDataTypeCookies }); } if data_kinds.cache { data_types.push(unsafe { WKWebsiteDataTypeDiskCache }); - data_types.push(unsafe { WKWebsiteDataTypeFetchCache }); - data_types.push(unsafe { WKWebsiteDataTypeMemoryCache }); - data_types.push(unsafe { WKWebsiteDataTypeOfflineWebApplicationCache }); } let data_types = NSSet::from_slice(&data_types); let date = NSDate::dateWithTimeIntervalSince1970(0.0); diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index 6084b54ffa2..25d1071e8f7 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -38,9 +38,7 @@ "enable": true, "scope": { "requireLiteralLeadingDot": false, - "allow": [ - "**/*" - ] + "allow": ["**/*"] } } } @@ -56,10 +54,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "binaries/wegent-executor", - "binaries/dws" - ], + "externalBin": ["binaries/wegent-executor", "binaries/dws"], "resources": [ "binaries/codex/**/*", "bundled-hooks/**/*", diff --git a/wework/src/App.apps.test.tsx b/wework/src/App.apps.test.tsx index de7595ec527..d6f12b7c911 100644 --- a/wework/src/App.apps.test.tsx +++ b/wework/src/App.apps.test.tsx @@ -23,11 +23,18 @@ vi.mock('@/lib/embedded-browser', async importOriginal => ({ vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ + label: 'main', startDragging: vi.fn(), minimize: vi.fn(), toggleMaximize: vi.fn(), close: vi.fn(), isMaximized: vi.fn().mockResolvedValue(false), + innerSize: vi.fn().mockResolvedValue({ + width: 1280, + height: 720, + toLogical: vi.fn().mockReturnValue({ width: 1280, height: 720 }), + }), + scaleFactor: vi.fn().mockResolvedValue(1), onResized: vi.fn().mockResolvedValue(vi.fn()), }), })) @@ -294,6 +301,15 @@ describe('App center route', () => { await waitForStartupScreenToClose() expect(screen.getByTestId('chrome-titlebar')).toBeInTheDocument() + const routeHost = screen.getByTestId('app-route-host') + const appShell = screen.getByTestId('app-shell') + expect(appShell).toHaveClass('fixed', 'inset-0') + expect(appShell).not.toHaveClass('h-dvh', 'h-screen') + await waitFor(() => { + expect(appShell).toHaveStyle({ width: '1280px', height: '720px' }) + }) + expect(routeHost).toHaveClass('flex-1', 'min-h-0') + expect(routeHost).not.toHaveClass('h-0') expect(screen.getByTestId('workbench-page')).toBeInTheDocument() expect(screen.getByTestId('desktop-workbench-surface')).toHaveClass('h-full') expect(screen.getByTestId('desktop-workbench-surface')).not.toHaveClass('app-view-surface') @@ -329,10 +345,26 @@ describe('App center route', () => { target: { value: 'second task draft' }, }) + const taskContents = screen.getAllByTestId(/^workspace-tab-content-task-/) + expect(taskContents).toHaveLength(2) + expect( + taskContents.filter(content => content.getAttribute('aria-hidden') === 'true') + ).toHaveLength(1) + fireEvent.click(taskTabs[0]) await waitFor(() => expect(firstInput).toHaveValue('first task draft')) + expect(taskContents[0]).toHaveAttribute('aria-hidden', 'false') + expect(taskContents[0]).toHaveClass('relative', 'h-full') + expect(taskContents[0]).not.toHaveClass('absolute', 'invisible') + expect(taskContents[1]).toHaveAttribute('aria-hidden', 'true') + expect(taskContents[1]).toHaveClass('absolute', 'inset-0', 'invisible', 'pointer-events-none') fireEvent.click(taskTabs[1]) await waitFor(() => expect(secondInput).toHaveValue('second task draft')) + expect(taskContents[0]).toHaveAttribute('aria-hidden', 'true') + expect(taskContents[0]).toHaveClass('absolute', 'inset-0', 'invisible', 'pointer-events-none') + expect(taskContents[1]).toHaveAttribute('aria-hidden', 'false') + expect(taskContents[1]).toHaveClass('relative', 'h-full') + expect(taskContents[1]).not.toHaveClass('absolute', 'invisible') expect(firstInput).toHaveValue('first task draft') }) diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index 015a324bd14..25d3c28abc5 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -25,6 +25,12 @@ vi.mock('@tauri-apps/api/window', () => ({ toggleMaximize: vi.fn(), close: vi.fn(), isMaximized: vi.fn().mockResolvedValue(false), + innerSize: vi.fn().mockResolvedValue({ + width: 1280, + height: 720, + toLogical: vi.fn().mockReturnValue({ width: 1280, height: 720 }), + }), + scaleFactor: vi.fn().mockResolvedValue(1), onResized: vi.fn().mockResolvedValue(vi.fn()), }), })) diff --git a/wework/src/App.tsx b/wework/src/App.tsx index 36f2cd2d881..b2bce56d303 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -95,6 +95,11 @@ import { setActiveWorkspaceTabPortalOwner } from '@/components/topnav/workspaceT const WORKBENCH_STARTUP_REVEAL_TIMEOUT_MS = 6000 const POPOUT_WINDOW_LABEL = 'popout-window' +interface ViewportSize { + width: number + height: number +} + function isPopoutWindowRuntime() { if (!isTauriRuntime()) return false try { @@ -253,13 +258,21 @@ function WorkspaceTabSurface({ const renderedIframe = iframe ?? surfaceHistory.iframe const renderProvider = surfaceHistory.hasMountedProvider || !iframe const renderWorkbench = surfaceHistory.hasMountedWorkbench || nativeWorkbenchActive + // Task-scoped browser routing is owned by workbench effects and must remain + // available while another workspace tab is active. + const keepTaskRuntimeActive = tab.kind === 'task' && renderWorkbench return ( - +
{renderProvider ? ( (() => { + if (!isTauri || window.innerWidth <= 0 || window.innerHeight <= 0) return null + return { width: window.innerWidth, height: window.innerHeight } + }) + + useEffect(() => { + if (!isTauri) return undefined + + const appWindow = getCurrentWindow() + let disposed = false + let unlisten: (() => void) | undefined + + const applyPhysicalSize = async ( + physicalSize: Awaited> + ) => { + const scaleFactor = await appWindow.scaleFactor() + const logicalSize = physicalSize.toLogical(scaleFactor) + if (disposed || logicalSize.width <= 0 || logicalSize.height <= 0) return + setViewportSize(current => + current?.width === logicalSize.width && current.height === logicalSize.height + ? current + : { width: logicalSize.width, height: logicalSize.height } + ) + } + + void appWindow + .innerSize() + .then(applyPhysicalSize) + .catch(error => { + console.error('[Wework] Failed to read the Tauri viewport size:', error) + }) + + void appWindow + .onResized(({ payload }) => { + void applyPhysicalSize(payload).catch(error => { + console.error('[Wework] Failed to update the Tauri viewport size:', error) + }) + }) + .then(unlistenFn => { + if (disposed) { + unlistenFn() + return + } + unlisten = unlistenFn + }) + .catch(error => { + console.error('[Wework] Failed to listen for Tauri viewport changes:', error) + }) + + return () => { + disposed = true + unlisten?.() + } + }, [isTauri]) + + return viewportSize +} + function AppShell() { const { t } = useTranslation('common') const { pathname: path, search } = useCurrentLocation() @@ -457,6 +529,7 @@ function AppShell() { } const { activeAppKey, navigateToApp } = useChromeTabs(path) const isTauri = isTauriRuntime() + const tauriViewportSize = useTauriViewportSize(isTauri) const isPopoutWindow = isPopoutWindowRuntime() const isWorkspaceWindow = isTauri && getCurrentWindow().label?.startsWith('workspace-') === true const titlebarOverlaysContent = false @@ -672,8 +745,10 @@ function AppShell() { labels={workspaceTabLabels} >
{showChromeTitlebar && ( ) : null}
({ enabled: true })) const deliveryApiMock = vi.hoisted(() => ({ available: false, listCloudProjects: vi.fn(), + listCloudFiles: vi.fn(), + listLoopItems: vi.fn(), + listDeliveries: vi.fn(), })) const cloudDesktopExtensionMock = vi.hoisted(() => { const launch = vi.fn() @@ -550,6 +553,9 @@ describe('DesktopWorkbenchLayout', () => { vi.clearAllMocks() deliveryApiMock.available = false deliveryApiMock.listCloudProjects.mockResolvedValue({ items: [] }) + deliveryApiMock.listCloudFiles.mockResolvedValue({ items: [] }) + deliveryApiMock.listLoopItems.mockResolvedValue({ items: [] }) + deliveryApiMock.listDeliveries.mockResolvedValue({ items: [] }) cloudDesktopExtensionMock.available = false cloudDesktopExtensionMock.launch.mockResolvedValue(true) Object.defineProperty(window, 'innerWidth', { @@ -1021,6 +1027,9 @@ describe('DesktopWorkbenchLayout', () => { ? { deliveryApi: { listCloudProjects: deliveryApiMock.listCloudProjects, + listCloudFiles: deliveryApiMock.listCloudFiles, + listLoopItems: deliveryApiMock.listLoopItems, + listDeliveries: deliveryApiMock.listDeliveries, }, } : {}), @@ -1961,6 +1970,113 @@ describe('DesktopWorkbenchLayout', () => { expect(await screen.findByTestId('mention-cloud-space-direct-action')).toBeInTheDocument() }) + test('treats a runtime task missing from runtime work as a new project-space task', async () => { + deliveryApiMock.available = true + deliveryApiMock.listCloudProjects.mockResolvedValue({ + items: [ + { + id: 'space-1', + public_id: 'public-space-1', + project_key: 'SPACE-1', + name: 'Task Follow-up Board', + description: '', + project_store: 'local', + task_provider: 'local', + provider_config: {}, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-08-09T00:00:00Z', + updated_at: '2026-08-09T00:00:00Z', + }, + ], + }) + const currentProject = activeProjectState.currentProject + const runtimeWork = structuredClone(createRuntimeWorkForProject(currentProject)!) + runtimeWork.projects[0].project.defaultProjectSpace = { + projectStore: 'local', + projectId: 'space-1', + } + + render( + + ) + + expect(await screen.findByTestId('project-space-context-pill')).toHaveTextContent( + '加入看板 · Task Follow-up Board' + ) + }) + + test('rechecks project spaces when a retained task workbench becomes visible again', async () => { + deliveryApiMock.available = true + deliveryApiMock.listCloudProjects.mockResolvedValue({ items: [] }) + const currentProject = activeProjectState.currentProject + const runtimeWork = structuredClone(createRuntimeWorkForProject(currentProject)!) + runtimeWork.projects[0].project.defaultProjectSpace = { + projectStore: 'local', + projectId: 'space-1', + } + const props = { + ...baseProps, + state: { + ...activeProjectState, + runtimeWork, + }, + projectWork: { + ...baseProps.projectWork, + currentProject, + currentProjectId: currentProject.id, + runtimeWork, + }, + } + const { rerender } = render() + + await waitFor(() => expect(deliveryApiMock.listCloudProjects).toHaveBeenCalledOnce()) + + rerender() + deliveryApiMock.listCloudProjects.mockResolvedValue({ + items: [ + { + id: 'space-1', + public_id: 'public-space-1', + project_key: 'SPACE-1', + name: 'Task Follow-up Board', + description: '', + project_store: 'local', + task_provider: 'local', + provider_config: {}, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-08-09T00:00:00Z', + updated_at: '2026-08-09T00:00:00Z', + }, + ], + }) + rerender() + + await waitFor(() => expect(deliveryApiMock.listCloudProjects).toHaveBeenCalledTimes(2)) + }) + test('forks an earlier completed turn without stopping the running follow-up', async () => { const currentRuntimeTask = { deviceId: 'device-1', diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index c943260a7ae..fe7fa866559 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -511,6 +511,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const supervisorDialogOpen = supervisorDialogTaskKey === supervisorDialogScopeKey const runtimeWork = state.runtimeWork const runtimeTaskSummary = findRuntimeTask(runtimeWork, currentRuntimeTask) + const currentProjectSpaceRuntimeTask = runtimeTaskSummary ? currentRuntimeTask : null const runtimeTaskTitle = truncateRuntimeTaskTitle(runtimeTaskSummary?.title) const { activeDeliveryItem, @@ -538,7 +539,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ todoBindingPickerOpen, visibleCloudMentionCandidates, } = useWorkbenchCloudProjectContext({ - currentRuntimeTask, + active: paneActive && workbenchVisible, + currentRuntimeTask: currentProjectSpaceRuntimeTask, currentProjectId: currentProject?.id, defaultProjectSpace, paneKey, @@ -547,7 +549,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ userId: state.user?.id, }) const pendingProjectSpaceContext = - !currentRuntimeTask && pendingCloudProject ? ( + !currentProjectSpaceRuntimeTask && pendingCloudProject ? ( 0 && ( diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx index 5a409cba3f5..fdaea65d450 100644 --- a/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.test.tsx @@ -54,6 +54,7 @@ function loopItem(projectId: string): CloudLoopItem { function renderCloudContext(services?: WorkbenchServices) { return renderHook(() => useWorkbenchCloudProjectContext({ + active: true, currentRuntimeTask: null, currentProjectId: 42, defaultProjectSpace: null, @@ -93,6 +94,7 @@ describe('useWorkbenchCloudProjectContext', () => { const { result, rerender } = renderHook( ({ currentRuntimeTask }: { currentRuntimeTask: RuntimeTaskAddress | null }) => useWorkbenchCloudProjectContext({ + active: true, currentRuntimeTask, currentProjectId: 42, defaultProjectSpace: null, @@ -148,6 +150,7 @@ describe('useWorkbenchCloudProjectContext', () => { const { result } = renderHook(() => useWorkbenchCloudProjectContext({ + active: true, currentRuntimeTask: null, currentProjectId: 42, defaultProjectSpace, @@ -165,6 +168,47 @@ describe('useWorkbenchCloudProjectContext', () => { expect(deliveryApi.listLoopItems).toHaveBeenCalledOnce() }) + test('reloads project spaces when a retained workbench becomes active again', async () => { + const defaultProject = project('space-default') + let projects: CloudProject[] = [] + const deliveryApi = { + listCloudProjects: vi.fn().mockImplementation(async () => ({ items: projects })), + listCloudFiles: vi.fn().mockResolvedValue({ items: [] }), + listLoopItems: vi.fn().mockResolvedValue({ items: [] }), + } + const services = { + deliveryApi, + } as unknown as WorkbenchServices + const defaultProjectSpace = { + projectStore: defaultProject.project_store, + projectId: defaultProject.id, + } as const + const { result, rerender } = renderHook( + ({ active }: { active: boolean }) => + useWorkbenchCloudProjectContext({ + active, + currentRuntimeTask: null, + currentProjectId: 42, + defaultProjectSpace, + paneKey: 'project:42', + runtimeTaskTitle: null, + services, + userId: 1, + }), + { initialProps: { active: true } } + ) + + await waitFor(() => expect(deliveryApi.listCloudProjects).toHaveBeenCalledOnce()) + expect(result.current.pendingCloudProject).toBeNull() + + rerender({ active: false }) + projects = [defaultProject] + rerender({ active: true }) + + await waitFor(() => expect(deliveryApi.listCloudProjects).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.pendingCloudProject).toEqual(defaultProject)) + }) + test('loads mentions from the selected local project-space API', async () => { const localProject = project('space-local', 'local') const item = loopItem(localProject.id) diff --git a/wework/src/components/layout/useWorkbenchCloudProjectContext.ts b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts index 0e6b70cf538..91e54421792 100644 --- a/wework/src/components/layout/useWorkbenchCloudProjectContext.ts +++ b/wework/src/components/layout/useWorkbenchCloudProjectContext.ts @@ -44,6 +44,7 @@ interface CloudSubmissionContext { } interface UseWorkbenchCloudProjectContextOptions { + active: boolean currentRuntimeTask: RuntimeTaskAddress | null currentProjectId?: number defaultProjectSpace: RuntimeProjectSpaceRef | null @@ -157,6 +158,7 @@ function cloudProjectAdditionalContext( } export function useWorkbenchCloudProjectContext({ + active: contextActive, currentRuntimeTask, currentProjectId, defaultProjectSpace, @@ -447,6 +449,7 @@ export function useWorkbenchCloudProjectContext({ : [] useEffect(() => { + if (!contextActive) return let active = true const apis = todoBindingApis if (!apis.length) { @@ -478,7 +481,7 @@ export function useWorkbenchCloudProjectContext({ return () => { active = false } - }, [todoBindingApis]) + }, [contextActive, todoBindingApis]) useEffect(() => { const pendingAutoJoin = pendingAutoJoinResolutionRef.current