From f3364d5f1c97876e52600ebe47c83188b794b3f3 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 12 Aug 2026 23:58:00 +0800 Subject: [PATCH 1/4] feat(wework): redesign split task workbench --- pnpm-lock.yaml | 14 + wework/e2e/desktop/checkpoints.mjs | 1 + wework/e2e/desktop/run-checkpoints.mjs | 7 +- .../scenarios/local-terminal.scenario.mjs | 8 +- .../scenarios/split-workbench.scenario.mjs | 651 +++++++++++++ wework/package.json | 1 + wework/src/App.plugins.test.tsx | 4 + .../src/components/layout/DesktopSidebar.tsx | 31 + .../layout/DesktopWorkbenchLayout.test.tsx | 51 +- .../layout/DesktopWorkbenchMain.tsx | 448 ++++++--- .../components/layout/SidebarSortableList.tsx | 49 +- .../layout/useWorkbenchPaneSession.ts | 14 +- .../components/layout/workbenchPaneDrag.ts | 35 + .../layout/workbenchPaneIdentity.ts | 35 + .../layout/workbenchPanePresentation.tsx | 111 +++ .../layout/workbenchPaneStack.module.css | 22 + .../layout/workbenchPaneStack.test.tsx | 291 ++++++ .../components/layout/workbenchPaneStack.tsx | 908 ++++++++++++++++-- .../layout/workbenchSplitLayout.test.ts | 133 +++ .../components/layout/workbenchSplitLayout.ts | 398 ++++++++ .../workspace-panels/BottomWorkspacePanel.tsx | 11 +- .../workspace-panels/RightWorkspacePanel.tsx | 5 +- .../WorkspacePanelActions.tsx | 4 +- .../useResizableWorkspacePanel.ts | 6 +- .../topnav/TitlebarActionsPortal.tsx | 24 + wework/src/e2e/automation.ts | 41 +- .../features/workbench/WorkbenchProvider.tsx | 75 +- .../workbench/workbenchContextTypes.ts | 4 + wework/src/i18n/locales/en/common.json | 18 + wework/src/i18n/locales/zh-CN/common.json | 18 + 30 files changed, 3090 insertions(+), 328 deletions(-) create mode 100644 wework/e2e/desktop/scenarios/split-workbench.scenario.mjs create mode 100644 wework/src/components/layout/workbenchPaneDrag.ts create mode 100644 wework/src/components/layout/workbenchPanePresentation.tsx create mode 100644 wework/src/components/layout/workbenchPaneStack.module.css create mode 100644 wework/src/components/layout/workbenchPaneStack.test.tsx create mode 100644 wework/src/components/layout/workbenchSplitLayout.test.ts create mode 100644 wework/src/components/layout/workbenchSplitLayout.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192e2fdb66e..7bae498d3d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,6 +684,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-resizable-panels: + specifier: ^4.12.2 + version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-syntax-highlighter: specifier: ^15.6.6 version: 15.6.6(react@19.2.7) @@ -7525,6 +7528,12 @@ packages: '@types/react': optional: true + react-resizable-panels@4.12.2: + resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-stately@3.47.0: resolution: {integrity: sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==} peerDependencies: @@ -16995,6 +17004,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-resizable-panels@4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately@3.47.0(react@19.2.7): dependencies: '@internationalized/date': 3.12.2 diff --git a/wework/e2e/desktop/checkpoints.mjs b/wework/e2e/desktop/checkpoints.mjs index 7bd5ef5ef9c..4acbdd9e298 100644 --- a/wework/e2e/desktop/checkpoints.mjs +++ b/wework/e2e/desktop/checkpoints.mjs @@ -8,6 +8,7 @@ export const DESKTOP_CHECKPOINTS = [ 'permission-modes', 'core-task-flow', 'runtime-task-queue', + 'split-workbench', 'window-lifecycle', 'goal-lifecycle', 'supervisor-lifecycle', diff --git a/wework/e2e/desktop/run-checkpoints.mjs b/wework/e2e/desktop/run-checkpoints.mjs index 3174d3815af..55b5afee3a8 100644 --- a/wework/e2e/desktop/run-checkpoints.mjs +++ b/wework/e2e/desktop/run-checkpoints.mjs @@ -16,9 +16,14 @@ const CHECKPOINT_SCENARIO_MODULES = { 'browser-multi-tabs': './scenarios/embedded-browser-multi-tabs.scenario.mjs', 'rendering-extensions': './scenarios/streaming-text.scenario.mjs', 'runtime-task-queue': './scenarios/runtime-task-queue.scenario.mjs', + 'split-workbench': './scenarios/split-workbench.scenario.mjs', 'project-automation': './scenarios/project-automation.scenario.mjs', } -const SCENARIO_ONLY_CHECKPOINTS = new Set(['local-harness', 'runtime-task-queue']) +const SCENARIO_ONLY_CHECKPOINTS = new Set([ + 'local-harness', + 'runtime-task-queue', + 'split-workbench', +]) const scriptDir = dirname(fileURLToPath(import.meta.url)) const taskFlowPath = join(scriptDir, 'task-flow.e2e.mjs') const cliArgs = process.argv.slice(2) diff --git a/wework/e2e/desktop/scenarios/local-terminal.scenario.mjs b/wework/e2e/desktop/scenarios/local-terminal.scenario.mjs index f62109779b5..e23c430f6bb 100644 --- a/wework/e2e/desktop/scenarios/local-terminal.scenario.mjs +++ b/wework/e2e/desktop/scenarios/local-terminal.scenario.mjs @@ -368,8 +368,8 @@ async function verifyHarnessWorkbenchChrome({ }) assert.match( bottomPanelClass, - /(?:^|\s)opacity-100(?:\s|$)/, - `${title} did not apply the open bottom-panel style` + /(?:^|\s)pointer-events-auto(?:\s|$)/, + `${title} did not make the open bottom workspace panel interactive` ) assert.match( bottomPanelInlineStyle, @@ -380,6 +380,10 @@ async function verifyHarnessWorkbenchChrome({ await control.command('getElementMetrics', bottomPanelSelector) ) assert.ok(bottomPanelMetrics.width > 0, `${title} opened a zero-width bottom workspace panel`) + assert.ok( + bottomPanelMetrics.height >= 220, + `${title} opened an undersized bottom workspace panel` + ) assert.ok( bottomPanelMetrics.scrollHeight > 0, `${title} opened a bottom workspace panel without content` diff --git a/wework/e2e/desktop/scenarios/split-workbench.scenario.mjs b/wework/e2e/desktop/scenarios/split-workbench.scenario.mjs new file mode 100644 index 00000000000..6a827626e8b --- /dev/null +++ b/wework/e2e/desktop/scenarios/split-workbench.scenario.mjs @@ -0,0 +1,651 @@ +import assert from 'node:assert/strict' + +const ACTIVE_SURFACE = '[data-workspace-tab-content][aria-hidden="false"]' +const COMPOSER = '[data-testid="desktop-empty-composer-frame"] [data-testid="chat-message-input"]' +const FIRST_PROMPT = 'SPLIT LEFT TASK' +const SECOND_PROMPT = 'SPLIT RIGHT TASK' +const PANE_SELECTOR = '[data-testid^="workbench-pane-"][data-focused]' + +function sse(events) { + return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('') +} + +function responseCreated(id) { + return { type: 'response.created', response: { id } } +} + +function responseCompleted(id) { + return { + type: 'response.completed', + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + } +} + +function assistantMessage(text) { + return { + type: 'response.output_item.done', + item: { + id: `wework-split-message-${Date.now()}`, + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + }, + } +} + +async function readJson(request) { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function json(response, status, body) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }) + response.end(JSON.stringify(body)) +} + +async function waitForProjectWorkButton(control, timeoutMs) { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if ( + Number(await control.command('getElementCount', '[data-testid="project-work-button"]')) > 0 + ) { + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error('The split-workbench project selector did not become available') +} + +async function waitForFolderPath(control, workspacePath, timeoutMs) { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if ( + (await control.command('getValue', '[data-testid="device-folder-path-input"]')) === + workspacePath + ) { + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error('The split-workbench folder picker did not retain the workspace path') +} + +async function createLocalProject(control, workspacePath, timeoutMs) { + await waitForProjectWorkButton(control, timeoutMs) + await control.command('click', '[data-testid="project-work-button"]') + await control.command('click', '[data-testid="add-local-project-option"]') + await control.command('waitFor', '[data-testid="device-folder-path-input"]', { timeoutMs }) + await control.command('fill', '[data-testid="device-folder-path-input"]', { + value: workspacePath, + }) + await control.command('press', '[data-testid="device-folder-path-input"]', { key: 'Enter' }) + await waitForFolderPath(control, workspacePath, timeoutMs) + await control.command('clickWhenEnabled', '[data-testid="confirm-device-folder-picker-button"]', { + timeoutMs, + }) + await control.command('waitFor', '[data-testid="local-project-create-dialog"]', { timeoutMs }) + await control.command('fill', '[data-testid="local-project-create-name-input"]', { + value: 'split-workbench', + }) + await control.command('clickWhenEnabled', '[data-testid="confirm-local-project-create-button"]', { + timeoutMs, + }) + await control.command('waitFor', '[data-testid="project-work-button"]', { + text: 'split-workbench', + timeoutMs, + }) +} + +async function waitForNewTaskRow(control, knownRows, expectedText, timeoutMs) { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const snapshot = JSON.parse(await control.command('snapshot', 'body')) + const candidates = snapshot.testIds.filter( + testId => testId.startsWith('runtime-local-task-row-') && !knownRows.has(testId) + ) + for (const testId of candidates) { + if ((await control.command('getText', `[data-testid="${testId}"]`)).includes(expectedText)) { + return testId + } + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`The split-workbench task row did not appear for ${expectedText}`) +} + +async function waitForElementCount(control, selector, expectedCount, timeoutMs) { + const startedAt = Date.now() + let stableMatches = 0 + while (Date.now() - startedAt < timeoutMs) { + const count = Number(await control.command('getElementCount', selector)) + stableMatches = count === expectedCount ? stableMatches + 1 : 0 + if (stableMatches >= 3) return + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Expected ${expectedCount} elements for ${selector}`) +} + +async function createTask(control, prompt, timeoutMs) { + const snapshot = JSON.parse(await control.command('snapshot', 'body')) + const knownRows = new Set( + snapshot.testIds.filter(testId => testId.startsWith('runtime-local-task-row-')) + ) + await control.command('fill', COMPOSER, { value: prompt }) + await control.command('press', COMPOSER, { key: 'Enter' }) + await control.command('waitFor', `${ACTIVE_SURFACE} [data-testid="message-assistant"]`, { + text: `${prompt}_COMPLETE`, + timeoutMs, + }) + return waitForNewTaskRow(control, knownRows, prompt, timeoutMs) +} + +async function expandProject(control, timeoutMs) { + const selector = `${ACTIVE_SURFACE} [data-testid="project-item-button"]` + if ((await control.command('getAttribute', selector, { value: 'aria-expanded' })) !== 'true') { + await control.command('click', selector) + } + await control.command('waitFor', `${selector}[aria-expanded="true"]`, { + stableMs: 300, + timeoutMs, + }) +} + +async function paneSelectorForTitle(control, title) { + const testId = await control.command('getAttribute', PANE_SELECTOR, { + text: title, + value: 'data-testid', + }) + assert.ok(testId, `Unable to resolve the task pane for ${title}`) + return `[data-testid="${testId}"]` +} + +async function assertPaneConversation(control, paneSelector, expectedText) { + await control.command('waitFor', `${paneSelector} [data-testid="message-assistant"]`, { + text: expectedText, + visible: true, + stableMs: 300, + }) + const [metrics] = JSON.parse(await control.command('getElementMetrics', paneSelector)) + assert.ok(metrics.width > 0 && metrics.height > 0, `${expectedText} pane has no visible area`) +} + +async function assertIndependentTitlebar(control, paneSelector, title) { + assert.equal( + Number( + await control.command( + 'getElementCount', + `${paneSelector} [data-testid^="workbench-pane-header-actions-"]` + ) + ), + 1, + `${title} does not own a titlebar action area` + ) + for (const testId of [ + 'open-code-server-titlebar-button', + 'environment-info-button', + 'toggle-bottom-workspace-panel-button', + 'toggle-right-workspace-panel-button', + ]) { + assert.equal( + Number(await control.command('getElementCount', `${paneSelector} [data-testid="${testId}"]`)), + 1, + `${title} does not own ${testId}` + ) + } +} + +export function createDesktopScenario({ captureScreenshot, uiTimeoutMs, workspacePath }) { + let active = false + return { + async handleHttp(request, response, url) { + if (request.method === 'GET' && url.pathname === '/api/auth/wework/config') { + json(response, 200, { + web_url: `${url.protocol}//${url.host}`, + socket_url: `${url.protocol}//${url.host}`, + }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/users/me') { + json(response, 401, { detail: 'Desktop split E2E runs without a cloud session' }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/teams') { + json(response, 200, { items: [], total: 0 }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/devices') { + json(response, 200, { items: [] }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/models/unified') { + json(response, 200, { data: [] }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/quota/claude/quota') { + json(response, 200, {}) + return true + } + if (request.method === 'GET' && url.pathname === '/api/runtime-work/im-notifications') { + json(response, 200, { + global: { enabled: false, sessionKey: null, session: null }, + runtimeTaskSubscriptions: [], + }) + return true + } + if (request.method === 'GET' && url.pathname === '/api/v1/runtime-tasks/cloud-context') { + json(response, 200, null) + return true + } + if (request.method === 'PUT' && url.pathname === '/api/users/me') { + const body = await readJson(request) + json(response, 200, body) + return true + } + if (request.method === 'GET' && url.pathname === '/api/apps/installed') { + json(response, 200, { apps: [] }) + return true + } + if (request.method === 'POST' && url.pathname === '/api/connector-runtime/token') { + json(response, 200, { + access_token: 'desktop-e2e-connector-token', + token_type: 'bearer', + expires_in: 3_600, + }) + return true + } + if ( + request.method === 'POST' && + url.pathname === '/api/v1/loop-item-executions/claim-my-next' + ) { + json(response, 200, null) + return true + } + if (request.method === 'GET' && url.pathname === '/api/v1/cloud-projects') { + json(response, 200, { items: [] }) + return true + } + if ( + request.method === 'PUT' && + url.pathname === '/api/runtime-work/im-notifications/presence' + ) { + const body = await readJson(request) + json(response, 200, { away: Boolean(body.away), ttlSeconds: 60 }) + return true + } + if ( + active && + request.method === 'POST' && + ['/v1/responses', '/responses'].includes(url.pathname) + ) { + const body = await readJson(request) + const input = JSON.stringify(body.input ?? body.messages ?? []) + const prompt = input.includes(SECOND_PROMPT) + ? SECOND_PROMPT + : input.includes(FIRST_PROMPT) + ? FIRST_PROMPT + : null + if (!prompt) return false + const responseId = `wework-split-${Date.now()}` + response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }) + response.end( + sse([ + responseCreated(responseId), + assistantMessage(`${prompt}_COMPLETE`), + responseCompleted(responseId), + ]) + ) + return true + } + return false + }, + + async verify(control) { + active = true + const taskTimeoutMs = Math.max(uiTimeoutMs, 30_000) + await createLocalProject(control, workspacePath, uiTimeoutMs) + await control.command('waitFor', COMPOSER, { timeoutMs: uiTimeoutMs }) + const firstTaskRow = await createTask(control, FIRST_PROMPT, taskTimeoutMs) + + await control.command('click', '[data-testid="new-chat-button"]') + await control.command('waitFor', COMPOSER, { stableMs: 500, timeoutMs: uiTimeoutMs }) + const secondTaskRow = await createTask(control, SECOND_PROMPT, taskTimeoutMs) + const firstTaskId = firstTaskRow.replace('runtime-local-task-row-', '') + const secondTaskId = secondTaskRow.replace('runtime-local-task-row-', '') + + await expandProject(control, uiTimeoutMs) + const sidebar = `${ACTIVE_SURFACE} [data-testid="desktop-sidebar"]` + const firstRow = `${sidebar} [data-testid="${firstTaskRow}"]` + const secondRow = `${sidebar} [data-testid="${secondTaskRow}"]` + await control.command('waitFor', firstRow, { + text: FIRST_PROMPT, + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + await control.command('waitFor', secondRow, { + text: SECOND_PROMPT, + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 1, + 'Single-task mode must render one pane' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid="workbench-main-header"]')), + 1, + 'Single-task mode must keep the shared main header' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid^="workbench-pane-title-"]')), + 0, + 'Single-task mode must not duplicate task title chrome' + ) + await assertPaneConversation(control, PANE_SELECTOR, `${SECOND_PROMPT}_COMPLETE`) + await captureScreenshot(control, '01-split-single-task.png', 'body') + + await control.command( + 'dragStart', + `${sidebar} [data-testid="runtime-local-task-drag-activator-${firstTaskId}"]`, + { target: PANE_SELECTOR, timeoutMs: uiTimeoutMs } + ) + await control.command('waitFor', '[data-testid^="workbench-pane-drop-targets-"]', { + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + const centerTarget = + '[data-testid^="workbench-pane-drop-targets-"] [data-workbench-pane-drop-position="center"]' + const rightTarget = + '[data-testid^="workbench-pane-drop-targets-"] [data-workbench-pane-drop-position="right"]' + const [centerMetrics] = JSON.parse(await control.command('getElementMetrics', centerTarget)) + const [rightMetrics] = JSON.parse(await control.command('getElementMetrics', rightTarget)) + assert.ok( + centerMetrics.width > rightMetrics.width * 3 && + centerMetrics.width * centerMetrics.height > rightMetrics.width * rightMetrics.height * 3, + `The current-window drop target is not dominant: ${JSON.stringify({ + centerMetrics, + rightMetrics, + })}` + ) + await captureScreenshot(control, '02-split-drag-targets.png', 'body') + + const splitStartedAt = Date.now() + await control.command('dragEnd', 'body', { + target: rightTarget, + timeoutMs: uiTimeoutMs, + }) + await control.command('waitFor', '[role="separator"][data-separator]', { + visible: true, + timeoutMs: uiTimeoutMs, + }) + const splitDurationMs = Date.now() - splitStartedAt + assert.ok( + splitDurationMs < 3_000, + `Sidebar drag took too long to create a split: ${splitDurationMs}ms` + ) + console.log(`[split-workbench] sidebar drag created the split in ${splitDurationMs}ms`) + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 2, + 'Sidebar edge drop must create exactly two panes' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid="workbench-main-header"]')), + 0, + 'Split mode must remove the shared task toolbar' + ) + const firstPane = await paneSelectorForTitle(control, FIRST_PROMPT) + const secondPane = await paneSelectorForTitle(control, SECOND_PROMPT) + await assertPaneConversation(control, firstPane, `${FIRST_PROMPT}_COMPLETE`) + await assertPaneConversation(control, secondPane, `${SECOND_PROMPT}_COMPLETE`) + await assertIndependentTitlebar(control, firstPane, FIRST_PROMPT) + await assertIndependentTitlebar(control, secondPane, SECOND_PROMPT) + await captureScreenshot(control, '03-split-two-independent-panes.png', 'body') + + const firstFocus = `${firstPane} [data-testid^="workbench-focus-pane-"]` + await control.command('click', firstFocus) + await control.command( + 'waitFor', + '[data-testid="workbench-split-layout"][data-focused-pane]', + { stableMs: 300, timeoutMs: uiTimeoutMs } + ) + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 1, + 'Focused task view must show one pane' + ) + assert.equal( + Number(await control.command('getElementCount', '[role="separator"][data-separator]')), + 0, + 'Focused task view must hide split separators' + ) + await assertPaneConversation(control, firstPane, `${FIRST_PROMPT}_COMPLETE`) + await captureScreenshot(control, '04-split-focused-task.png', 'body') + + await control.command('click', firstFocus) + await control.command('waitFor', '[role="separator"][data-separator]', { + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 2, + 'Returning from focus view must restore both panes' + ) + await captureScreenshot(control, '05-split-restored.png', 'body') + + await control.command('click', `${firstPane} [data-testid="environment-info-button"]`) + await control.command('waitFor', '[data-testid="environment-info-popover"]', { + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + assert.equal( + Number( + await control.command( + 'getElementCount', + [ + `${firstPane} [data-testid="environment-info-button"][aria-expanded="true"]`, + `${secondPane} [data-testid="environment-info-button"][aria-expanded="false"]`, + ].join(', ') + ) + ), + 2, + 'Environment state leaked into the other pane' + ) + await captureScreenshot(control, '06-split-local-environment.png', 'body') + await control.command('click', `${firstPane} [data-testid="environment-info-button"]`) + await waitForElementCount(control, '[data-testid="environment-info-popover"]', 0, uiTimeoutMs) + + await control.command( + 'click', + `${firstPane} [data-testid="toggle-bottom-workspace-panel-button"]` + ) + await control.command( + 'waitFor', + `${firstPane} [data-testid="bottom-workspace-panel"][aria-hidden="false"]`, + { visible: true, stableMs: 300, timeoutMs: uiTimeoutMs } + ) + const [bottomPanelMetrics] = JSON.parse( + await control.command( + 'getElementMetrics', + `${firstPane} [data-testid="bottom-workspace-panel"][aria-hidden="false"]` + ) + ) + const [firstPaneMetrics] = JSON.parse(await control.command('getElementMetrics', firstPane)) + assert.ok( + bottomPanelMetrics.height >= 220 && + bottomPanelMetrics.top >= firstPaneMetrics.top && + bottomPanelMetrics.bottom <= firstPaneMetrics.bottom, + `Bottom workspace panel is outside its pane: ${JSON.stringify({ + bottomPanelMetrics, + paneMetrics: firstPaneMetrics, + })}` + ) + assert.equal( + Number( + await control.command( + 'getElementCount', + `${secondPane} [data-testid="bottom-workspace-panel"][aria-hidden="false"]` + ) + ), + 0, + 'Bottom panel state leaked into the other pane' + ) + await captureScreenshot(control, '07-split-local-bottom-panel.png', 'body') + await control.command( + 'click', + `${firstPane} [data-testid="toggle-bottom-workspace-panel-button"]` + ) + await control.command( + 'waitFor', + `${firstPane} [data-testid="bottom-workspace-panel"][aria-hidden="true"]`, + { stableMs: 300, timeoutMs: uiTimeoutMs } + ) + + await control.command( + 'click', + `${secondPane} [data-testid="toggle-right-workspace-panel-button"]` + ) + await control.command( + 'waitFor', + `${secondPane} [data-testid="right-workspace-panel-shell"][aria-hidden="false"]`, + { visible: true, stableMs: 300, timeoutMs: uiTimeoutMs } + ) + await control.command('waitFor', `${secondPane} [data-testid="right-workspace-launcher"]`, { + visible: true, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + await control.command( + 'waitFor', + `${secondPane} [data-testid="right-workspace-browser-option"]`, + { visible: true, stableMs: 300, timeoutMs: uiTimeoutMs } + ) + const [rightPanelMetrics] = JSON.parse( + await control.command( + 'getElementMetrics', + `${secondPane} [data-testid="right-workspace-panel-shell"]` + ) + ) + const [rightLauncherMetrics] = JSON.parse( + await control.command( + 'getElementMetrics', + `${secondPane} [data-testid="right-workspace-launcher"]` + ) + ) + const [rightBrowserOptionMetrics] = JSON.parse( + await control.command( + 'getElementMetrics', + `${secondPane} [data-testid="right-workspace-browser-option"]` + ) + ) + const [secondPaneMetrics] = JSON.parse(await control.command('getElementMetrics', secondPane)) + assert.ok( + rightPanelMetrics.width >= 260 && + rightLauncherMetrics.width >= 180 && + rightBrowserOptionMetrics.width >= 180 && + rightPanelMetrics.left >= secondPaneMetrics.left && + rightPanelMetrics.right <= secondPaneMetrics.right, + `Right workspace panel is not visibly contained by its pane: ${JSON.stringify({ + browserOptionMetrics: rightBrowserOptionMetrics, + launcherMetrics: rightLauncherMetrics, + paneMetrics: secondPaneMetrics, + panelMetrics: rightPanelMetrics, + })}` + ) + assert.equal( + Number( + await control.command( + 'getElementCount', + `${firstPane} [data-testid="right-workspace-panel-shell"][aria-hidden="false"]` + ) + ), + 0, + 'Right panel state leaked into the other pane' + ) + await captureScreenshot(control, '08-split-local-right-panel.png', 'body') + await control.command( + 'click', + `${secondPane} [data-testid="toggle-right-workspace-panel-button"]` + ) + await control.command( + 'waitFor', + `${secondPane} [data-testid="right-workspace-panel-shell"][aria-hidden="true"]`, + { stableMs: 300, timeoutMs: uiTimeoutMs } + ) + + const [beforeResize] = JSON.parse(await control.command('getElementMetrics', secondPane)) + await control.command('drag', '[role="separator"][data-separator]', { + target: firstPane, + timeoutMs: uiTimeoutMs, + }) + const [afterResize] = JSON.parse(await control.command('getElementMetrics', secondPane)) + assert.ok( + Math.abs(afterResize.width - beforeResize.width) > 40, + 'Dragging the separator did not visibly resize the split' + ) + await captureScreenshot(control, '09-split-resized.png', 'body') + + const readyCountBeforeReload = control.readyCount + await control.command('reloadMainWindow', 'body') + await control.awaitReadyAfter(readyCountBeforeReload) + await control.command('waitFor', PANE_SELECTOR, { timeoutMs: uiTimeoutMs }) + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 2, + 'Reloading did not restore both panes' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid^="workbench-pane-title-"]')), + 2, + 'Reloading restored hidden task tabs or lost a pane title' + ) + const restoredFirstPane = await paneSelectorForTitle(control, FIRST_PROMPT) + const restoredSecondPane = await paneSelectorForTitle(control, SECOND_PROMPT) + await assertPaneConversation(control, restoredFirstPane, `${FIRST_PROMPT}_COMPLETE`) + await assertPaneConversation(control, restoredSecondPane, `${SECOND_PROMPT}_COMPLETE`) + await captureScreenshot(control, '10-split-restored-after-reload.png', 'body') + + await control.command('click', `${restoredFirstPane} [data-testid^="workbench-close-pane-"]`) + await control.command('waitFor', PANE_SELECTOR, { + text: SECOND_PROMPT, + stableMs: 300, + timeoutMs: uiTimeoutMs, + }) + assert.equal( + Number(await control.command('getElementCount', PANE_SELECTOR)), + 1, + 'Closing a pane did not collapse the split' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid="workbench-main-header"]')), + 1, + 'Returning to single-task mode did not restore the shared header' + ) + assert.equal( + Number(await control.command('getElementCount', '[data-testid^="workbench-pane-title-"]')), + 0, + 'Returning to single-task mode retained split title chrome' + ) + await captureScreenshot(control, '11-split-return-single.png', 'body') + + assert.ok(firstTaskId && secondTaskId) + }, + } +} diff --git a/wework/package.json b/wework/package.json index 656e746f2bd..031d6e4258f 100644 --- a/wework/package.json +++ b/wework/package.json @@ -109,6 +109,7 @@ "react-dom": "^19.2.6", "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.12.2", "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "streamdown": "2.5.0", diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index e5c026eda79..0d8671660ac 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -128,6 +128,8 @@ const workbenchValue: WorkbenchContextValue = { projectWorktreeBranch: null, setProjectWorktreeBranch: vi.fn(), projectChat: { + scopeKey: 'blank:0', + inputByScope: { 'blank:0': '' }, models: [], skills: [], selectedModel: null, @@ -140,6 +142,8 @@ const workbenchValue: WorkbenchContextValue = { isAttachmentReadyToSend: true, setSelectedModel: vi.fn(), setSelectedModelOption: vi.fn(), + setInput: vi.fn(), + setInputForScope: vi.fn(), setSelectedSkills: vi.fn(), toggleSkill: vi.fn(), handleFileSelect: vi.fn(), diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index 8ef98f2c7f2..a7baff0472c 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -122,6 +122,7 @@ import type { ArchiveRuntimeTaskOptions, ArchiveRuntimeTaskResult, } from '@/features/workbench/workbenchContextTypes' +import { getWorkbenchPaneKey } from './workbenchPaneIdentity' import { DesktopSettingsMenu } from './DesktopSettingsMenu' import { DesktopWindowControls } from './DesktopWindowControls' import { @@ -2786,6 +2787,16 @@ function ProjectItem({ `${workspace.deviceId}:${getRuntimeTaskThreadId(task) || task.taskId}` } getLabel={({ task }) => task.title} + getExternalDragData={({ workspace, task }) => ({ + paneKey: getWorkbenchPaneKey({ + currentRuntimeTask: { + deviceId: workspace.deviceId, + taskId: task.taskId, + }, + currentProject: null, + }), + title: task.title, + })} canDrag={({ task }) => Boolean( getRuntimeTaskThreadId(task) && @@ -3921,6 +3932,16 @@ export function DesktopSidebar({ `${workspace.deviceId}:${getRuntimeTaskThreadId(task) || task.taskId}` } getLabel={({ task }) => task.title} + getExternalDragData={({ workspace, task }) => ({ + paneKey: getWorkbenchPaneKey({ + currentRuntimeTask: { + deviceId: workspace.deviceId, + taskId: task.taskId, + }, + currentProject: null, + }), + title: task.title, + })} canDrag={({ task }) => Boolean(getRuntimeTaskThreadId(task) && onSetRuntimeTaskPinned) } @@ -4309,6 +4330,16 @@ export function DesktopSidebar({ `${workspace.deviceId}:${getRuntimeTaskThreadId(task) || task.taskId}` } getLabel={({ task }) => task.title} + getExternalDragData={({ workspace, task }) => ({ + paneKey: getWorkbenchPaneKey({ + currentRuntimeTask: { + deviceId: workspace.deviceId, + taskId: task.taskId, + }, + currentProject: null, + }), + title: task.title, + })} canDrag={({ task }) => Boolean(getRuntimeTaskThreadId(task) && onReorderRuntimeProjectTasks) } diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index 036eb3591b3..970b8c273a6 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -5309,20 +5309,22 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.queryByTestId('workspace-tool-launcher')).not.toBeInTheDocument() expect(screen.getByTestId('right-workspace-resize-handle')).toHaveAttribute('role', 'separator') expect(screen.getByTestId('right-workspace-resize-handle')).toHaveClass( - 'absolute', - 'bottom-[-6px]', - 'top-0', + 'relative', + '-mx-[3px]', 'w-1.5', - '-translate-x-1/2', + 'shrink-0', + 'self-stretch', 'cursor-col-resize' ) - expect(screen.getByTestId('right-workspace-resize-handle')).toHaveStyle({ left: '420px' }) const content = screen.getByTestId('desktop-workbench-content') const rightPanelShell = screen.getByTestId('right-workspace-panel-shell') await waitFor(() => { expect(content).toHaveStyle({ width: '420px' }) - expect(rightPanelShell).toHaveStyle({ width: '580px' }) + expect(rightPanelShell).toHaveStyle({ + minWidth: '260px', + width: 'calc(100% - 420px)', + }) }) expect(panel).toHaveClass('min-w-0', 'flex-1', 'basis-0') expect(panel).toHaveClass('transition-[opacity,transform]', 'duration-300', 'ease-out') @@ -5337,7 +5339,7 @@ describe('DesktopWorkbenchLayout', () => { fireEvent.pointerUp(document) expect(content).toHaveStyle({ width: '580px' }) - expect(rightPanelShell).toHaveStyle({ width: '420px' }) + expect(rightPanelShell).toHaveStyle({ width: 'calc(100% - 580px)' }) expect(screen.getByTestId('workspace-file-tree')).toHaveClass('w-[240px]') }) @@ -5389,7 +5391,7 @@ describe('DesktopWorkbenchLayout', () => { await waitFor(() => { expect(content).toHaveStyle({ width: '420px' }) - expect(panelShell).toHaveStyle({ width: '580px' }) + expect(panelShell).toHaveStyle({ width: 'calc(100% - 420px)' }) }) expect(screen.getByTestId('right-workspace-resize-handle')).toBeInTheDocument() expect(screen.getByTestId('project-chat-composer')).toBeInTheDocument() @@ -5560,7 +5562,7 @@ describe('DesktopWorkbenchLayout', () => { await waitFor(() => { expect(content).toHaveStyle({ width: '420px' }) - expect(rightPanelShell).toHaveStyle({ width: '580px' }) + expect(rightPanelShell).toHaveStyle({ width: 'calc(100% - 420px)' }) }) fireEvent.pointerDown(screen.getByTestId('right-workspace-resize-handle'), { clientX: 422 }) @@ -5569,7 +5571,7 @@ describe('DesktopWorkbenchLayout', () => { expect(content).toHaveClass('transition-none') expect(rightPanelShell).toHaveClass('transition-none') expect(content).toHaveStyle({ width: '700px' }) - expect(rightPanelShell).toHaveStyle({ width: '300px' }) + expect(rightPanelShell).toHaveStyle({ width: 'calc(100% - 700px)' }) fireEvent.pointerMove(document, { clientX: 902 }) @@ -5585,7 +5587,7 @@ describe('DesktopWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('toggle-right-workspace-panel-button')) expect(content).toHaveStyle({ width: '420px' }) - expect(rightPanelShell).toHaveStyle({ width: '580px' }) + expect(rightPanelShell).toHaveStyle({ width: 'calc(100% - 420px)' }) expect(screen.getByTestId('workspace-browser-url-input')).toHaveValue('https://weibo.com/') }) @@ -5875,7 +5877,7 @@ describe('DesktopWorkbenchLayout', () => { await waitFor(() => { expect(content).toHaveStyle({ width: '420px' }) expect(topBar).toHaveStyle({ width: '420px' }) - expect(rightPanelShell).toHaveStyle({ width: '580px' }) + expect(rightPanelShell).toHaveStyle({ width: 'calc(100% - 420px)' }) }) expect(rightPanelShell).toHaveClass('opacity-100') expect(screen.queryByTestId('workbench-topbar-right-actions')).not.toBeInTheDocument() @@ -5979,7 +5981,7 @@ describe('DesktopWorkbenchLayout', () => { await waitFor(() => { expect(screen.getByTestId('desktop-workbench-content')).toHaveStyle({ width: '580px' }) expect(screen.getByTestId('right-workspace-panel-shell')).toHaveStyle({ - width: '420px', + width: 'calc(100% - 580px)', }) }) @@ -6343,7 +6345,9 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.getByTestId('titlebar-actions')).toHaveStyle({ right: '0px', }) - expect(screen.getByTestId('titlebar-right-workspace-zone').style.width).toContain('580px') + expect(screen.getByTestId('titlebar-right-workspace-zone').style.width).toContain( + 'calc(100% - 420px)' + ) expect(screen.getByTestId('right-workspace-resize-handle')).toHaveClass( 'after:bg-transparent' ) @@ -8936,14 +8940,14 @@ describe('DesktopWorkbenchLayout', () => { const panel = screen.getByTestId('bottom-workspace-panel') expect(panel).toBeInTheDocument() expect(panel).toHaveClass( - 'transition-[height,opacity,transform]', + 'transition-[height]', 'duration-300', 'ease-out', - 'pointer-events-auto', - 'translate-y-0', - 'opacity-100' + 'pointer-events-auto' ) + expect(panel).not.toHaveClass('translate-y-0', 'translate-y-3', 'opacity-0', 'opacity-100') expect(panel).toHaveAttribute('aria-hidden', 'false') + expect(panel).toHaveStyle({ flexBasis: '320px' }) expect(screen.getByTestId('desktop-workbench-content')).not.toContainElement(panel) expect(screen.getByTestId('desktop-workbench-main')).toContainElement(panel) expect(screen.getByTestId('toggle-bottom-workspace-panel-button')).toBeInTheDocument() @@ -8951,12 +8955,12 @@ describe('DesktopWorkbenchLayout', () => { fireEvent.pointerDown(screen.getByTestId('bottom-workspace-resize-handle'), { clientY: 700 }) expect(panel).toHaveClass('transition-none') - expect(panel).not.toHaveClass('transition-[height,opacity,transform]') + expect(panel).not.toHaveClass('transition-[height]') fireEvent.pointerMove(document, { clientY: 620 }) fireEvent.pointerUp(document) - expect(panel).toHaveStyle({ height: '400px' }) - expect(panel).toHaveClass('transition-[height,opacity,transform]', 'duration-300') + expect(panel).toHaveStyle({ flexBasis: '400px', height: '400px' }) + expect(panel).toHaveClass('transition-[height]', 'duration-300') }) test('starts a terminal directly when the bottom panel opens', async () => { @@ -9833,13 +9837,14 @@ describe('DesktopWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('toggle-bottom-workspace-panel-button')) const panel = screen.getByTestId('bottom-workspace-panel') expect(panel).toBeInTheDocument() - expect(panel).toHaveClass('opacity-100') + expect(panel).toHaveClass('pointer-events-auto') await userEvent.click(screen.getByTestId('close-bottom-workspace-panel-button')) expect(panel).toBeInTheDocument() expect(panel).toHaveStyle({ height: '0px' }) - expect(panel).toHaveClass('pointer-events-none', 'translate-y-3', 'opacity-0') + expect(panel).toHaveClass('pointer-events-none') + expect(panel).not.toHaveClass('translate-y-3', 'opacity-0') expect(panel).toHaveAttribute('aria-hidden', 'true') expect(screen.getByTestId('toggle-bottom-workspace-panel-button')).toBeInTheDocument() }) diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 9d7061d8a2c..69816d078f3 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -1,4 +1,13 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + memo, + useCallback, + useEffect, + useEffectEvent, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { Archive, ArrowLeftRight, @@ -70,7 +79,10 @@ import { type RightWorkspacePanelView, } from './workspace-panels/RightWorkspacePanel' import { WorkspacePanelActions } from './workspace-panels/WorkspacePanelActions' -import { useResizableRightSplitChat } from './workspace-panels/useResizableWorkspacePanel' +import { + RIGHT_SPLIT_PANEL_MIN_WIDTH, + useResizableRightSplitChat, +} from './workspace-panels/useResizableWorkspacePanel' import { ConversationDeviceOfflineBanner } from './ConversationDeviceOfflineBanner' import { DeviceStatusPrompt } from './DeviceStatusPrompt' import { @@ -78,7 +90,9 @@ import { TITLEBAR_RIGHT_PANEL_PORTAL_ID, TitlebarFeedbackPortal, WORKBENCH_MAIN_HEADER_PORTAL_ID, + WORKBENCH_SPLIT_ACTIONS_PORTAL_ID, WorkbenchMainHeaderPortal, + WorkbenchPaneHeaderActionsPortal, } from '@/components/topnav/TitlebarActionsPortal' import { DESKTOP_TOP_BAR_BUTTON_CLASS, DesktopTopBar } from './DesktopTopBar' import { DesktopWindowControls } from './DesktopWindowControls' @@ -137,9 +151,16 @@ import { pendingRequestUserInputPayload } from './requestUserInputOverlay' import { getRuntimeWorkbenchPaneKeys, getWorkbenchPaneKey, + resolveRuntimeWorkbenchPane, type WorkbenchPaneIdentity, } from './workbenchPaneIdentity' -import { CachedWorkbenchPaneStack, useWorkbenchPaneActive } from './workbenchPaneStack' +import { SplitWorkbenchPaneStack } from './workbenchPaneStack' +import { + useWorkbenchPaneActive, + useWorkbenchPaneId, + useWorkbenchPaneHeaderActionsPortalId, + useWorkbenchPaneVisible, +} from './workbenchPanePresentation' import { useWorkbenchPaneSession } from './useWorkbenchPaneSession' import { formatEnvironmentReviewErrorMessage, @@ -212,7 +233,6 @@ const TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH = 420 const MACOS_COLLAPSED_SIDEBAR_CONTROL_ALIGNMENT_CLASS = 'pl-2' const BLANK_BROWSER_MIGRATION_TTL_MS = 2 * 60 * 1000 -const MAX_CACHED_DESKTOP_WORKBENCH_PANES = 1 interface SelectedAssistantPlan { blockId: string subtaskId: string @@ -234,6 +254,16 @@ interface WorkbenchPaneWorkspaceState { } | null } +interface EnvironmentInfoVisibilityState { + pinned: boolean + overlayOpen: boolean +} + +const DEFAULT_ENVIRONMENT_INFO_VISIBILITY: EnvironmentInfoVisibilityState = { + pinned: true, + overlayOpen: false, +} + interface PendingBlankBrowserMigration { sourcePaneKey: string browserLabel: string @@ -450,7 +480,7 @@ const MemoizedBottomWorkspacePanel = memo(function MemoizedBottomWorkspacePanel( return ( + >({}) const [internalHarnessSessions, setInternalHarnessSessions] = useState< LocalHarnessWorkbenchSession[] >([]) @@ -552,14 +585,11 @@ export function DesktopWorkbenchMain(props: DesktopWorkbenchMainProps) { }, [onLocalHarnessSessionClose, removeLocalHarnessSession] ) - const [terminalPinOwnersByPane, setTerminalPinOwnersByPane] = useState>( - {} - ) + const [resourceOwnersByPane, setResourceOwnersByPane] = useState>({}) const paneWorkspaceStateRef = useRef(new Map()) - const terminalPinnedPaneKeys = useMemo( - () => - Object.keys(terminalPinOwnersByPane).filter(key => terminalPinOwnersByPane[key].length > 0), - [terminalPinOwnersByPane] + const retainedResourceKeys = useMemo( + () => Object.keys(resourceOwnersByPane).filter(key => resourceOwnersByPane[key].length > 0), + [resourceOwnersByPane] ) const runtimePaneKeys = useMemo( () => getRuntimeWorkbenchPaneKeys(state.runtimeWork), @@ -567,36 +597,49 @@ export function DesktopWorkbenchMain(props: DesktopWorkbenchMainProps) { ) const runtimePaneKeySet = useMemo(() => new Set(runtimePaneKeys), [runtimePaneKeys]) const activePaneKey = getWorkbenchPaneKey(props.activePane) - const prunedPaneKeys = useMemo( - () => - terminalPinnedPaneKeys.filter( - key => key.startsWith('runtime:') && !runtimePaneKeySet.has(key) - ), - [runtimePaneKeySet, terminalPinnedPaneKeys] + const setPaneResourceRetained = useCallback( + (paneKey: string, owner: string, retained: boolean) => { + setResourceOwnersByPane(current => { + const owners = current[paneKey] ?? [] + const nextOwners = retained + ? owners.includes(owner) + ? owners + : [...owners, owner] + : owners.filter(candidate => candidate !== owner) + if ( + nextOwners.length === owners.length && + nextOwners.every((value, index) => value === owners[index]) + ) { + return current + } + if (nextOwners.length === 0) { + const next = { ...current } + delete next[paneKey] + return next + } + return { ...current, [paneKey]: nextOwners } + }) + }, + [] ) - const setTerminalPanePinned = useCallback((paneKey: string, owner: string, pinned: boolean) => { - setTerminalPinOwnersByPane(current => { - const owners = current[paneKey] ?? [] - const nextOwners = pinned - ? owners.includes(owner) - ? owners - : [...owners, owner] - : owners.filter(candidate => candidate !== owner) - if (nextOwners === owners) return current - if (nextOwners.length === 0) { - const next = { ...current } - delete next[paneKey] - return next - } - return { ...current, [paneKey]: nextOwners } - }) - }, []) const rememberPaneWorkspaceState = useCallback( (paneKey: string, workspaceState: WorkbenchPaneWorkspaceState) => { paneWorkspaceStateRef.current.set(paneKey, workspaceState) }, [] ) + const updateEnvironmentInfoVisibility = useCallback( + (paneId: string, patch: Partial) => { + setEnvironmentInfoVisibilityByPane(current => ({ + ...current, + [paneId]: { + ...(current[paneId] ?? DEFAULT_ENVIRONMENT_INFO_VISIBILITY), + ...patch, + }, + })) + }, + [] + ) useEffect(() => { paneWorkspaceStateRef.current.forEach((_, key) => { if (key.startsWith('runtime:') && !runtimePaneKeySet.has(key)) { @@ -604,40 +647,94 @@ export function DesktopWorkbenchMain(props: DesktopWorkbenchMainProps) { } }) }, [runtimePaneKeySet]) + const resolvePane = useCallback( + (paneKey: string) => { + if (paneKey === activePaneKey) return props.activePane + return resolveRuntimeWorkbenchPane(state.runtimeWork, paneKey) + }, + [activePaneKey, props.activePane, state.runtimeWork] + ) + const getPaneTitle = useCallback( + (pane: WorkbenchPaneIdentity) => { + if (!pane.currentRuntimeTask) { + return pane.currentProject?.name ?? t('workbench.new_chat', '新任务') + } + const task = findRuntimeTask(state.runtimeWork, pane.currentRuntimeTask) + return ( + truncateRuntimeTaskTitle(task?.title) ?? + t('workbench.task_fallback_title', { + defaultValue: '任务 {{taskId}}', + taskId: pane.currentRuntimeTask.taskId, + }) + ) + }, + [state.runtimeWork, t] + ) + const focusPane = useCallback( + (pane: WorkbenchPaneIdentity) => { + if (pane.currentRuntimeTask) void openRuntimeTask(pane.currentRuntimeTask) + }, + [openRuntimeTask] + ) + const renderWorkbenchPane = useCallback( + (pane: WorkbenchPaneIdentity) => ( + + ), + [ + activeLocalHarnessSession, + activePaneKey, + closeHarnessSession, + environmentInfoVisibilityByPane, + localHarnessSessions, + props.onSidebarCollapsedChange, + props.showComposerProjectMenuAction, + props.sidebarCollapsed, + props.sidebarResizing, + props.visible, + registerLocalHarnessSession, + rememberPaneWorkspaceState, + removeLocalHarnessSession, + setPaneResourceRetained, + services?.workspaceSessionApi, + updateEnvironmentInfoVisibility, + updateHarnessSessionTitle, + ] + ) const paneStack = ( - ( - - )} + workbenchVisible={props.visible ?? true} + resolvePane={resolvePane} + getPaneTitle={getPaneTitle} + onPaneFocus={focusPane} + onSplitModeChange={setSplitMode} + renderPane={renderWorkbenchPane} /> ) @@ -649,14 +746,16 @@ export function DesktopWorkbenchMain(props: DesktopWorkbenchMainProps) { return (
-
+ {!splitMode ? ( +
+ ) : null} {mainContent}
) @@ -669,12 +768,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ sidebarResizing = false, showComposerProjectMenuAction, workspaceSessionApi, - environmentInfoPinned, - environmentInfoOverlayOpen, + environmentInfoVisibilityByPane, onSidebarCollapsedChange, - onEnvironmentInfoPinnedChange, - onEnvironmentInfoOverlayOpenChange, - onTerminalPanePinChange, + onEnvironmentInfoVisibilityChange, + onPaneResourceRetained, initialWorkspaceState, onWorkspaceStateChange, onLocalHarnessSessionStarted, @@ -690,12 +787,13 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ sidebarResizing?: boolean showComposerProjectMenuAction: boolean workspaceSessionApi?: WorkspaceSessionApi - environmentInfoPinned: boolean - environmentInfoOverlayOpen: boolean + environmentInfoVisibilityByPane: Record onSidebarCollapsedChange: (collapsed: boolean) => void - onEnvironmentInfoPinnedChange: (open: boolean) => void - onEnvironmentInfoOverlayOpenChange: (open: boolean) => void - onTerminalPanePinChange: (paneKey: string, owner: string, pinned: boolean) => void + onEnvironmentInfoVisibilityChange: ( + paneId: string, + patch: Partial + ) => void + onPaneResourceRetained: (paneKey: string, owner: string, retained: boolean) => void initialWorkspaceState?: WorkbenchPaneWorkspaceState onWorkspaceStateChange: (paneKey: string, state: WorkbenchPaneWorkspaceState) => void onLocalHarnessSessionStarted: ( @@ -709,6 +807,14 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onLocalHarnessSessionExit: (sessionId: string) => void }) { const paneActive = useWorkbenchPaneActive() + const paneVisible = useWorkbenchPaneVisible() + const paneId = useWorkbenchPaneId() + const paneHeaderActionsPortalId = useWorkbenchPaneHeaderActionsPortalId() + const splitMode = paneHeaderActionsPortalId !== null + const environmentInfoVisibility = + environmentInfoVisibilityByPane[paneId] ?? DEFAULT_ENVIRONMENT_INFO_VISIBILITY + const environmentInfoPinned = environmentInfoVisibility.pinned + const environmentInfoOverlayOpen = environmentInfoVisibility.overlayOpen const paneActiveRef = useRef(paneActive) const experimentalFeaturesEnabled = useExperimentalFeaturesEnabled() const appPreferences = useAppPreferencesState() @@ -1270,7 +1376,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ setEnvironmentInfoPanelElement(environmentInfoPanelRef.current) }, []) useLayoutEffect(() => { - if (!paneActive) return + if (!paneActive || !paneVisible) return const workbenchMain = workbenchMainRef.current if (workbenchMain && workbenchMain.scrollLeft !== 0) { workbenchMain.scrollLeft = 0 @@ -1279,7 +1385,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ if (workbenchScroll && workbenchScroll.scrollLeft !== 0) { workbenchScroll.scrollLeft = 0 } - }, [activeLocalHarnessSession?.sessionId, paneActive]) + }, [activeLocalHarnessSession?.sessionId, paneActive, paneVisible]) const continueInIm = useRuntimeTaskContinueInIm(currentRuntimeTask) const closeRightPanel = () => { setRightPanelExpanded(false) @@ -1313,8 +1419,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onCollapse: closeRightPanel, defaultPanelWidth: onlyTemporaryChatOpen ? TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH : undefined, }) - const rightPanelTransitionDisabled = rightSplitResizing || rightPanelImmediateLayout + const rightPanelTransitionDisabled = splitMode || rightSplitResizing || rightPanelImmediateLayout const chatColumnWidth = rightPanelOpen && !rightPanelExpanded ? rightSplitChatWidth : '100%' + const chatColumnMaxWidth = + rightPanelOpen && !rightPanelExpanded ? `calc(100% - ${RIGHT_SPLIT_PANEL_MIN_WIDTH}px)` : '100%' const availableChatColumnWidth = rightPanelOpen ? rightSplitChatWidth : workbenchContentWidth const environmentInfoDocked = Boolean(currentRuntimeTask) && @@ -1327,45 +1435,42 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ (open: boolean) => { if (environmentInfoDocked) { setEnvironmentInfoTransitionEnabled(true) - onEnvironmentInfoPinnedChange(open) + onEnvironmentInfoVisibilityChange(paneId, { pinned: open }) return } - onEnvironmentInfoOverlayOpenChange(open) + onEnvironmentInfoVisibilityChange(paneId, { overlayOpen: open }) }, [ environmentInfoDocked, - onEnvironmentInfoOverlayOpenChange, - onEnvironmentInfoPinnedChange, + onEnvironmentInfoVisibilityChange, + paneId, setEnvironmentInfoTransitionEnabled, ] ) const openSupervisorDialog = useCallback(() => { setSupervisorDialogTaskKey(supervisorDialogScopeKey) if (!environmentInfoDocked) { - onEnvironmentInfoOverlayOpenChange(false) + onEnvironmentInfoVisibilityChange(paneId, { overlayOpen: false }) } }, [ environmentInfoDocked, - onEnvironmentInfoOverlayOpenChange, + onEnvironmentInfoVisibilityChange, + paneId, setSupervisorDialogTaskKey, supervisorDialogScopeKey, ]) useEffect(() => { if (currentRuntimeTask && !environmentInfoDocked) return - onEnvironmentInfoOverlayOpenChange(false) - }, [currentRuntimeTask, environmentInfoDocked, onEnvironmentInfoOverlayOpenChange]) + onEnvironmentInfoVisibilityChange(paneId, { overlayOpen: false }) + }, [currentRuntimeTask, environmentInfoDocked, onEnvironmentInfoVisibilityChange, paneId]) const paneTitleWidth = rightPanelOpen ? chatColumnWidth : '100%' - const rightPanelWidth = Math.max(0, workbenchContentWidth - rightSplitChatWidth) - const rightPanelShellWidth = rightPanelOpen - ? rightPanelExpanded - ? '100%' - : `${rightPanelWidth}px` - : '0px' + const rightPanelWidth = rightPanelExpanded ? '100%' : `calc(100% - ${rightSplitChatWidth}px)` + const rightPanelShellWidth = rightPanelOpen ? rightPanelWidth : '0px' const rightPanelTabBarRightOffset = '0px' const rightPanelTabBarWidth = rightPanelOpen - ? `calc(${rightPanelExpanded ? '100%' : `${rightPanelWidth}px`} - ${rightPanelTabBarRightOffset} - ${COLLAPSED_RIGHT_TITLEBAR_ACTIONS_CLEARANCE})` + ? `calc(${rightPanelWidth} - ${rightPanelTabBarRightOffset} - ${COLLAPSED_RIGHT_TITLEBAR_ACTIONS_CLEARANCE})` : '0px' const temporaryChatAvailable = !activeLocalHarnessSession const effectiveRightPanelTabs = useMemo(() => { @@ -1405,9 +1510,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ tab === 'terminal' || isRightWorkspaceBrowserTab(tab) || isRightWorkspaceHarnessTab(tab) ) useEffect(() => { - onTerminalPanePinChange(paneKey, 'right-panel', hasPersistentRightPanelResource) - return () => onTerminalPanePinChange(paneKey, 'right-panel', false) - }, [hasPersistentRightPanelResource, onTerminalPanePinChange, paneKey]) + onPaneResourceRetained(paneKey, 'right-panel', hasPersistentRightPanelResource) + return () => onPaneResourceRetained(paneKey, 'right-panel', false) + }, [hasPersistentRightPanelResource, onPaneResourceRetained, paneKey]) const chatContentResizing = sidebarResizing || rightSplitResizing const activeDeviceId = currentRuntimeTask?.deviceId ?? @@ -2227,30 +2332,39 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ preferLocalTerminal: workspacePanelPrefersLocalTerminal, terminalContextTitle: workbenchTitle, } - const rememberActiveBottomPanelContext = () => { + const activeBottomPanelContextRef = useRef(activeBottomPanelContext) + useLayoutEffect(() => { + activeBottomPanelContextRef.current = { + key: bottomPanelWorkspaceKey, + currentProject: workspaceProject, + devices, + workspaceTarget: workspacePanelTarget, + preferLocalTerminal: workspacePanelPrefersLocalTerminal, + terminalContextTitle: workbenchTitle, + } + }, [ + bottomPanelWorkspaceKey, + devices, + workspacePanelPrefersLocalTerminal, + workspacePanelTarget, + workspaceProject, + workbenchTitle, + ]) + const rememberActiveBottomPanelContext = useCallback(() => { + const context = activeBottomPanelContextRef.current setBottomPanelContexts(current => { - const existingIndex = current.findIndex(context => context.key === bottomPanelWorkspaceKey) + const existingIndex = current.findIndex(item => item.key === context.key) if (existingIndex < 0) { - return [...current, activeBottomPanelContext] + return [...current, context] } - if (current[existingIndex] === activeBottomPanelContext) { + if (current[existingIndex] === context) { return current } const next = [...current] - next[existingIndex] = activeBottomPanelContext + next[existingIndex] = context return next }) - } - const setCurrentBottomPanelOpen = (next: boolean | ((open: boolean) => boolean)) => { - rememberActiveBottomPanelContext() - onTerminalPanePinChange(paneKey, 'bottom-panel', true) - setBottomPanelOpenByKey(current => { - const currentOpen = current[bottomPanelWorkspaceKey] ?? false - const nextOpen = typeof next === 'function' ? next(currentOpen) : next - if (currentOpen === nextOpen) return current - return { ...current, [bottomPanelWorkspaceKey]: nextOpen } - }) - } + }, []) const inactiveBottomPanelContexts = bottomPanelContexts.filter( context => context.key !== bottomPanelWorkspaceKey ) @@ -3010,7 +3124,17 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const toggleRightPanelExpanded = () => { setRightPanelExpanded(expanded => !expanded) } - const toggleBottomPanel = () => setCurrentBottomPanelOpen(open => !open) + const toggleBottomPanel = () => { + rememberActiveBottomPanelContext() + onPaneResourceRetained(paneKey, 'bottom-panel', true) + setBottomPanelOpenByKey(current => { + const open = current[bottomPanelWorkspaceKey] ?? false + return { ...current, [bottomPanelWorkspaceKey]: !open } + }) + } + const openTerminalPanel = useEffectEvent(() => { + toggleBottomPanel() + }) const { pauseCurrentResponse: pauseCurrentResponseAction, compactContext: compactContextAction, @@ -3040,25 +3164,21 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ () => void clearCurrentGoalAction(), [clearCurrentGoalAction] ) - const closeBottomPanelContext = useCallback( - (key: string) => { - setBottomPanelOpenByKey(current => ({ ...current, [key]: false })) - }, - [setBottomPanelOpenByKey] - ) + const closeBottomPanelContext = useCallback((key: string) => { + setBottomPanelOpenByKey(current => ({ ...current, [key]: false })) + }, []) const handleTerminalTabsEmpty = () => { - onTerminalPanePinChange(paneKey, 'bottom-panel', false) + onPaneResourceRetained(paneKey, 'bottom-panel', false) } - useEffect(() => { - if (!paneActive) return + if (!paneActive || !paneVisible) return const handleOpenTerminal = () => { - toggleBottomPanel() + openTerminalPanel() } window.addEventListener(WEWORK_OPEN_TERMINAL_EVENT, handleOpenTerminal) return () => window.removeEventListener(WEWORK_OPEN_TERMINAL_EVENT, handleOpenTerminal) - }, [paneActive, toggleBottomPanel]) + }, [paneActive, paneVisible]) const renderWorkspacePanelActions = ( mode: @@ -3298,6 +3418,11 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ rightPanelExpanded && 'invisible' )} > +
{mainHeaderActions}
) : undefined + const paneHeaderActions = activeLocalHarnessSession ? ( + <>{closeHarnessButton} + ) : ( + <> + {forkTaskButton} + {continueInImButton} + {mainHeaderProjectAction} + {mainHeaderEnvironmentAction} + {panelChromeActions} + + ) useLayoutEffect(() => { if (previousRightPanelSessionKey.current === rightPanelSessionKey) { return @@ -3384,10 +3520,19 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ )} > {/* Portals escape the hidden cached pane, so only the visible active pane may own the header. */} - {tauriMainHeaderContent && paneActive && workbenchVisible ? ( + {tauriMainHeaderContent && paneActive && workbenchVisible && !splitMode ? ( {tauriMainHeaderContent} ) : null} - {feedbackInChromeTitlebar && !activeLocalHarnessSession && paneActive && workbenchVisible ? ( + {paneHeaderActionsPortalId && paneVisible && workbenchVisible ? ( + + {paneHeaderActions} + + ) : null} + {feedbackInChromeTitlebar && + !activeLocalHarnessSession && + paneActive && + workbenchVisible && + !splitMode ? ( {feedbackButton} ) : null} <> @@ -3408,7 +3553,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ isTauri && sidebarCollapsed ? 'pl-[14rem]' : 'pl-4', rightPanelTransitionDisabled ? 'transition-none' : RIGHT_PANEL_WIDTH_TRANSITION_CLASS )} - style={{ width: chatColumnWidth }} + style={{ maxWidth: chatColumnMaxWidth, width: chatColumnWidth }} left={topBarLeftContent} leftClassName={cn('min-w-0 gap-2', isTauri ? 'contents' : 'max-w-[calc(100%-12rem)]')} /> @@ -3423,7 +3568,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ 'pointer-events-none absolute bottom-0 left-0 z-popover', showPageTopBar ? 'top-11' : 'top-0' )} - style={{ width: chatColumnWidth }} + style={{ maxWidth: chatColumnMaxWidth, width: chatColumnWidth }} />
{isBootstrapping ? (
@@ -3497,6 +3642,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ loadingMoreBefore={paneSession.transcriptLoadingMoreBefore} turnNavigation={paneSession.turnNavigation} loadedTranscriptRanges={paneSession.loadedTranscriptRanges} + autoScrollSuspended={!paneVisible || !workbenchVisible} onLoadMoreBefore={paneSession.loadMoreTranscriptBefore} onLoadFullTranscript={paneSession.loadFullTranscript} loadingFullTranscript={paneSession.transcriptLoadingFullContent} @@ -3634,7 +3780,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onChange={paneSession.setInput} onDraftEdit={paneSession.clearError} onSubmit={submitPaneInput} - disabled={composerDisabled} + disabled={composerDisabled || !paneVisible || !workbenchVisible} pluginPickerIconOnly={hasConversation} submitDisabled={paneSession.status.isSubmitting} error={paneSession.error} @@ -3816,7 +3962,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ centralHarnessStarting || ((activeNewChatRuntime === 'codex' || activeNewChatRuntime === 'claude_code') && - composerDisabled) + composerDisabled) || + !paneVisible || + !workbenchVisible } submitDisabled={ centralHarnessStarting || @@ -3972,10 +4120,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ aria-label={t('workbench.resize_right_workspace_panel')} aria-controls="right-workspace-panel-shell" className={cn( - 'absolute bottom-[-6px] top-0 z-critical w-1.5 -translate-x-1/2 cursor-col-resize bg-transparent after:absolute after:bottom-0 after:left-1/2 after:top-0 after:w-px after:-translate-x-1/2 after:bg-transparent after:transition-colors after:duration-150 after:ease-out hover:after:bg-primary/40', + 'relative z-critical -mx-[3px] w-1.5 shrink-0 self-stretch cursor-col-resize bg-transparent after:absolute after:bottom-0 after:left-1/2 after:top-0 after:w-px after:-translate-x-1/2 after:bg-transparent after:transition-colors after:duration-150 after:ease-out hover:after:bg-primary/40', rightPanelTransitionDisabled ? 'transition-none' : RIGHT_PANEL_HANDLE_TRANSITION_CLASS )} - style={{ left: rightSplitChatWidth }} onPointerDown={handleRightSplitResizeStart} /> )} @@ -3983,7 +4130,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ id="right-workspace-panel-shell" data-testid="right-workspace-panel-shell" className={cn( - 'z-popover min-w-0 shrink-0 overflow-hidden', + 'z-popover flex min-w-0 shrink-0 overflow-hidden', rightPanelExpanded ? 'absolute inset-y-0 right-0' : 'relative', rightPanelExpanded ? 'bg-background' @@ -3998,13 +4145,18 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ) : 'pointer-events-none opacity-0' )} - style={{ width: rightPanelShellWidth }} + style={{ + minWidth: + rightPanelOpen && !rightPanelExpanded ? RIGHT_SPLIT_PANEL_MIN_WIDTH : undefined, + width: rightPanelShellWidth, + }} aria-hidden={!rightPanelOpen} > {shouldRenderRightPanel && ( { getId: (item: T) => string getLabel: (item: T) => string canDrag?: (item: T) => boolean + getExternalDragData?: (item: T) => WorkbenchSidebarPaneDragData renderItem: (item: T) => ReactNode onMove: (item: T, beforeItem: T | null) => Promise className?: string @@ -84,6 +91,7 @@ export function SidebarSortableList({ getId, getLabel, canDrag = () => true, + getExternalDragData, renderItem, onMove, className, @@ -109,9 +117,32 @@ export function SidebarSortableList({ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) - const handleDragStart = ({ active }: DragStartEvent) => setActiveId(String(active.id)) - const handleDragEnd = async ({ active, over }: DragEndEvent) => { + const handleDragStart = ({ active }: DragStartEvent) => { + const activeId = String(active.id) + setActiveId(activeId) + const item = itemById.get(activeId) + const externalData = item ? getExternalDragData?.(item) : undefined + if (externalData) dispatchWorkbenchSidebarPaneDragStart(externalData) + } + const handleDragEnd = async ({ active, delta, over }: DragEndEvent) => { setActiveId(null) + const movedItem = itemById.get(String(active.id)) + const externalData = movedItem ? getExternalDragData?.(movedItem) : undefined + if (externalData) { + const rect = active.rect.current.initial + if (rect) { + const dragEndData = { + ...externalData, + clientX: rect.left + rect.width / 2 + delta.x, + clientY: rect.top + rect.height / 2 + delta.y, + handled: false, + } + dispatchWorkbenchSidebarPaneDragEnd(dragEndData) + if (dragEndData.handled) return + } else { + dispatchWorkbenchSidebarPaneDragCancel() + } + } if (!over || active.id === over.id) return const oldIndex = orderedIds.indexOf(String(active.id)) const newIndex = orderedIds.indexOf(String(over.id)) @@ -119,9 +150,8 @@ export function SidebarSortableList({ const previousIds = orderedIds const nextIds = arrayMove(previousIds, oldIndex, newIndex) - const movedItem = itemById.get(String(active.id)) const beforeItem = itemById.get(nextIds[newIndex + 1] ?? '') ?? null - if (!movedItem) return + if (!movedItem || !canDrag(movedItem)) return setOptimisticOrder({ sourceSignature, ids: nextIds }) try { @@ -138,7 +168,10 @@ export function SidebarSortableList({ sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} - onDragCancel={() => setActiveId(null)} + onDragCancel={() => { + setActiveId(null) + dispatchWorkbenchSidebarPaneDragCancel() + }} onDragEnd={event => void handleDragEnd(event)} > @@ -147,7 +180,11 @@ export function SidebarSortableList({ const item = itemById.get(id) if (!item) return null return ( - + {renderItem(item)} ) diff --git a/wework/src/components/layout/useWorkbenchPaneSession.ts b/wework/src/components/layout/useWorkbenchPaneSession.ts index a62b7925747..146a080fc01 100644 --- a/wework/src/components/layout/useWorkbenchPaneSession.ts +++ b/wework/src/components/layout/useWorkbenchPaneSession.ts @@ -22,6 +22,7 @@ import { selectedModelExecutionFields, } from '@/features/workbench/runtimeModelSelection' import { findRuntimeTask } from '@/features/workbench/workbenchRuntimeHelpers' +import { getRuntimeTaskChatScopeKey } from '@/features/workbench/workbenchProviderHelpers' import { persistAttachmentReferences } from '@/lib/attachments' import { localRuntimeAttachments, remoteAttachmentIds } from '@/lib/runtime-attachments' import { @@ -152,7 +153,6 @@ const RUNTIME_TRANSCRIPT_PAGE_SIZE = ? configuredRuntimeTranscriptPageSize : DEFAULT_RUNTIME_TRANSCRIPT_PAGE_SIZE const MAX_CACHED_RUNTIME_PANE_GOALS = 3 -const noopSetInput = () => undefined export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSessionOptions) { const { @@ -206,8 +206,15 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes ) const [guidanceMessages] = useState([]) const [codeCommentContexts, setCodeCommentContexts] = useState([]) - const input = projectChat.input ?? '' - const scopedSetInput = projectChat.setInput ?? noopSetInput + const inputScopeKey = currentRuntimeTask + ? getRuntimeTaskChatScopeKey(currentRuntimeTask) + : projectChat.scopeKey + const input = projectChat.inputByScope[inputScopeKey] ?? '' + const setInputForScope = projectChat.setInputForScope + const scopedSetInput = useCallback( + (value: string) => setInputForScope(inputScopeKey, value), + [inputScopeKey, setInputForScope] + ) const [localError, setLocalError] = useState(null) const error = projectChat.setComposerError ? (projectChat.composerError ?? null) : localError const setError = projectChat.setComposerError ?? setLocalError @@ -2279,7 +2286,6 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes return false }, [ currentRuntime, - currentRuntimeTask, getRuntimeModelFields, goal, paneStatus.isBusy, diff --git a/wework/src/components/layout/workbenchPaneDrag.ts b/wework/src/components/layout/workbenchPaneDrag.ts new file mode 100644 index 00000000000..d5fc4f6453e --- /dev/null +++ b/wework/src/components/layout/workbenchPaneDrag.ts @@ -0,0 +1,35 @@ +export const WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT = 'wework:workbench-sidebar-pane-drag-start' +export const WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT = 'wework:workbench-sidebar-pane-drag-end' + +export interface WorkbenchSidebarPaneDragData { + paneKey: string + title: string +} + +export interface WorkbenchSidebarPaneDragEndData extends WorkbenchSidebarPaneDragData { + clientX: number + clientY: number + handled: boolean +} + +export function dispatchWorkbenchSidebarPaneDragStart(data: WorkbenchSidebarPaneDragData) { + window.dispatchEvent( + new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, { + detail: data, + }) + ) +} + +export function dispatchWorkbenchSidebarPaneDragEnd(data: WorkbenchSidebarPaneDragEndData) { + window.dispatchEvent( + new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, { + detail: data, + }) + ) +} + +export function dispatchWorkbenchSidebarPaneDragCancel() { + window.dispatchEvent( + new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, { detail: null }) + ) +} diff --git a/wework/src/components/layout/workbenchPaneIdentity.ts b/wework/src/components/layout/workbenchPaneIdentity.ts index 04d14539747..999327215ee 100644 --- a/wework/src/components/layout/workbenchPaneIdentity.ts +++ b/wework/src/components/layout/workbenchPaneIdentity.ts @@ -31,3 +31,38 @@ export function getRuntimeWorkbenchPaneKeys(runtimeWork: RuntimeWorkListResponse ) ) } + +export function resolveRuntimeWorkbenchPane( + runtimeWork: RuntimeWorkListResponse | null, + paneKey: string +): WorkbenchPaneIdentity | null { + if (!runtimeWork || !paneKey.startsWith('runtime:')) return null + const workspaces = [ + ...runtimeWork.chats, + ...runtimeWork.projects.flatMap(project => project.deviceWorkspaces), + ] + for (const workspace of workspaces) { + const task = workspace.tasks.find(candidate => { + const key = getWorkbenchPaneKey({ + currentRuntimeTask: { + deviceId: workspace.deviceId, + taskId: candidate.taskId, + }, + currentProject: null, + }) + return key === paneKey + }) + if (!task) continue + return { + currentRuntimeTask: { + deviceId: workspace.deviceId, + taskId: task.taskId, + threadId: task.threadId, + workspacePath: task.workspacePath || workspace.workspacePath, + runtimeHandle: task.runtimeHandle, + }, + currentProject: null, + } + } + return null +} diff --git a/wework/src/components/layout/workbenchPanePresentation.tsx b/wework/src/components/layout/workbenchPanePresentation.tsx new file mode 100644 index 00000000000..c436cb5353e --- /dev/null +++ b/wework/src/components/layout/workbenchPanePresentation.tsx @@ -0,0 +1,111 @@ +/* eslint-disable react-refresh/only-export-components -- Pane presentation hooks share this context. */ +import { + createContext, + memo, + useContext, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from 'react' +import { createPortal } from 'react-dom' +import type { WorkbenchPaneIdentity } from './workbenchPaneIdentity' + +export interface WorkbenchPanePresentation { + visible: boolean + focused: boolean + headerActionsPortalId: string | null + paneId: string +} + +const WorkbenchPanePresentationContext = createContext({ + visible: true, + focused: true, + headerActionsPortalId: null, + paneId: 'single', +}) + +function WorkbenchPanePresentationProvider({ + visible, + focused, + headerActionsPortalId, + paneId, + children, +}: WorkbenchPanePresentation & { children: ReactNode }) { + const value = useMemo( + () => ({ visible, focused, headerActionsPortalId, paneId }), + [focused, headerActionsPortalId, paneId, visible] + ) + return ( + + {children} + + ) +} + +export function useWorkbenchPaneActive() { + return useContext(WorkbenchPanePresentationContext).focused +} + +export function useWorkbenchPaneVisible() { + return useContext(WorkbenchPanePresentationContext).visible +} + +export function useWorkbenchPaneHeaderActionsPortalId() { + return useContext(WorkbenchPanePresentationContext).headerActionsPortalId +} + +export function useWorkbenchPaneId() { + return useContext(WorkbenchPanePresentationContext).paneId +} + +export function WorkbenchPaneHost({ host }: { host: HTMLDivElement }) { + const slotRef = useRef(null) + + useLayoutEffect(() => { + const slot = slotRef.current + if (!slot || host.parentElement === slot) return + slot.append(host) + return () => { + if (host.parentElement === slot) host.remove() + } + }, [host]) + + return
+} + +export function WorkbenchPanePortal({ + pane, + host, + renderPane, + visible, + focused, + headerActionsPortalId, + paneId, +}: WorkbenchPanePresentation & { + pane: WorkbenchPaneIdentity + host: HTMLDivElement + renderPane: (pane: WorkbenchPaneIdentity) => ReactNode +}) { + return createPortal( + + + , + host + ) +} + +const CachedWorkbenchPane = memo(function CachedWorkbenchPane({ + pane, + renderPane, +}: { + pane: WorkbenchPaneIdentity + renderPane: (pane: WorkbenchPaneIdentity) => ReactNode +}) { + return <>{renderPane(pane)} +}) diff --git a/wework/src/components/layout/workbenchPaneStack.module.css b/wework/src/components/layout/workbenchPaneStack.module.css new file mode 100644 index 00000000000..5f849ffee7a --- /dev/null +++ b/wework/src/components/layout/workbenchPaneStack.module.css @@ -0,0 +1,22 @@ +.paneGroup { + container-type: inline-size; +} + +@container (max-width: 44rem) { + .paneHeaderActions [data-testid='local-workspace-titlebar-control'] { + border-color: transparent; + background: transparent; + } + + .paneHeaderActions [data-testid='open-code-server-titlebar-button'] { + width: 1.75rem; + justify-content: center; + padding-left: 0; + padding-right: 0; + } + + .paneHeaderActions [data-testid='open-workspace-location-label'], + .paneHeaderActions [data-testid='open-local-workspace-picker-button'] { + display: none; + } +} diff --git a/wework/src/components/layout/workbenchPaneStack.test.tsx b/wework/src/components/layout/workbenchPaneStack.test.tsx new file mode 100644 index 00000000000..b78b62a2ce4 --- /dev/null +++ b/wework/src/components/layout/workbenchPaneStack.test.tsx @@ -0,0 +1,291 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + WORKBENCH_SPLIT_ACTIONS_PORTAL_ID, + WorkbenchPaneHeaderActionsPortal, +} from '@/components/topnav/TitlebarActionsPortal' +import { + WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, + WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, + type WorkbenchSidebarPaneDragEndData, +} from './workbenchPaneDrag' +import { getWorkbenchPaneKey, type WorkbenchPaneIdentity } from './workbenchPaneIdentity' +import { + useWorkbenchPaneActive, + useWorkbenchPaneHeaderActionsPortalId, + useWorkbenchPaneVisible, +} from './workbenchPanePresentation' +import { SplitWorkbenchPaneStack } from './workbenchPaneStack' +import { + collectWorkbenchPanes, + createWorkbenchLayout, + placeWorkbenchTask, + serializeWorkbenchLayout, +} from './workbenchSplitLayout' + +const paneOne: WorkbenchPaneIdentity = { + currentRuntimeTask: { deviceId: 'device', taskId: 'one' }, + currentProject: null, +} +const paneTwo: WorkbenchPaneIdentity = { + currentRuntimeTask: { deviceId: 'device', taskId: 'two' }, + currentProject: null, +} +const paneThree: WorkbenchPaneIdentity = { + currentRuntimeTask: { deviceId: 'device', taskId: 'three' }, + currentProject: null, +} +const blankPane: WorkbenchPaneIdentity = { + currentRuntimeTask: null, + currentProject: null, + standaloneChatKey: 1, +} +const panes = [paneOne, paneTwo, paneThree] +const paneByKey = new Map(panes.map(pane => [getWorkbenchPaneKey(pane), pane] as const)) +const paneOneKey = getWorkbenchPaneKey(paneOne) +const paneTwoKey = getWorkbenchPaneKey(paneTwo) +const paneThreeKey = getWorkbenchPaneKey(paneThree) + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} + } + ) +}) + +beforeEach(() => { + localStorage.removeItem('workbench-split-test') +}) + +function PaneContent({ pane }: { pane: WorkbenchPaneIdentity }) { + const portalId = useWorkbenchPaneHeaderActionsPortalId() + const active = useWorkbenchPaneActive() + const visible = useWorkbenchPaneVisible() + const [mountId] = useState(() => crypto.randomUUID()) + const name = pane.currentRuntimeTask?.taskId ?? 'blank' + return ( + <> + {visible && portalId ? ( + + + + ) : null} +
+ + ) +} + +function Stack({ + activePane, + onPaneFocus = () => undefined, + retainedResourceKeys = [], + workbenchVisible = true, +}: { + activePane: WorkbenchPaneIdentity + onPaneFocus?: (pane: WorkbenchPaneIdentity) => void + retainedResourceKeys?: string[] + workbenchVisible?: boolean +}) { + return ( + <> +
+ paneByKey.get(key) ?? null} + getPaneTitle={pane => pane.currentRuntimeTask?.taskId ?? 'blank'} + onPaneFocus={onPaneFocus} + renderPane={pane => } + /> + + ) +} + +function persistTwoPaneLayout() { + const initial = createWorkbenchLayout(paneOneKey) + const split = placeWorkbenchTask(initial, paneTwoKey, initial.focusedPaneId, 'right') + localStorage.setItem('workbench-split-test', serializeWorkbenchLayout(split)) + return split +} + +describe('SplitWorkbenchPaneStack', () => { + it('renders exactly one task in single-pane mode', () => { + render() + + expect(screen.getByTestId('pane-content-one')).toBeVisible() + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-active', 'true') + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-visible', 'true') + expect(screen.queryByTestId(/^workbench-pane-title-/)).not.toBeInTheDocument() + expect(screen.getAllByTestId('active-pane')).toHaveLength(1) + }) + + it('keeps the focused pane active for task-scoped resources while its workspace tab is hidden', () => { + persistTwoPaneLayout() + render() + + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-active', 'false') + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-visible', 'false') + expect(screen.getByTestId('pane-content-two')).toHaveAttribute('data-active', 'true') + expect(screen.getByTestId('pane-content-two')).toHaveAttribute('data-visible', 'false') + expect(screen.queryByTestId('pane-header-action-one')).not.toBeInTheDocument() + expect(screen.queryByTestId('pane-header-action-two')).not.toBeInTheDocument() + }) + + it('replaces the focused pane during ordinary sidebar navigation', async () => { + const view = render() + + view.rerender() + + await waitFor(() => { + expect(screen.getByTestId('pane-content-two')).toBeVisible() + expect(screen.queryByTestId('pane-content-one')).not.toBeInTheDocument() + }) + }) + + it('keeps retained resources mounted outside the document and restores their host', async () => { + const view = render() + const mountId = screen.getByTestId('pane-content-one').dataset.mountId + + view.rerender() + await waitFor(() => { + expect(screen.queryByTestId('pane-content-one')).not.toBeInTheDocument() + expect(screen.getByTestId('pane-content-two')).toBeVisible() + }) + + view.rerender() + await waitFor(() => + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-mount-id', mountId) + ) + }) + + it('restores two visible tasks with independent title bars and actions', async () => { + persistTwoPaneLayout() + render() + + expect(await screen.findByTestId('pane-content-one')).toBeVisible() + expect(screen.getByTestId('pane-content-two')).toBeVisible() + expect(screen.getAllByTestId(/^workbench-pane-title-/)).toHaveLength(2) + expect(screen.getByTestId('pane-header-action-one')).toBeVisible() + expect(screen.getByTestId('pane-header-action-two')).toBeVisible() + }) + + it('focuses an existing task without remounting either pane', async () => { + persistTwoPaneLayout() + const onPaneFocus = vi.fn() + const view = render() + const oneMount = screen.getByTestId('pane-content-one').dataset.mountId + const twoMount = screen.getByTestId('pane-content-two').dataset.mountId + + view.rerender() + + await waitFor(() => expect(screen.getByTestId('pane-content-two')).toBeVisible()) + expect(screen.getByTestId('pane-content-one')).toHaveAttribute('data-mount-id', oneMount) + expect(screen.getByTestId('pane-content-two')).toHaveAttribute('data-mount-id', twoMount) + }) + + it('shows one task in focus view and restores the split with Escape', async () => { + persistTwoPaneLayout() + render() + const focusButtons = screen.getAllByTestId(/^workbench-focus-pane-/) + + fireEvent.click(focusButtons[0]) + await waitFor(() => + expect(screen.getByTestId('workbench-split-layout')).toHaveAttribute('data-focused-pane') + ) + expect(screen.getByTestId('pane-content-one')).toBeVisible() + expect(screen.queryByTestId('pane-content-two')).not.toBeInTheDocument() + + fireEvent.keyDown(window, { key: 'Escape' }) + await waitFor(() => expect(screen.getByTestId('pane-content-two')).toBeVisible()) + }) + + it('removes the complete pane when its close action is used', async () => { + persistTwoPaneLayout() + render() + + fireEvent.click(screen.getAllByTestId(/^workbench-close-pane-/)[1]) + + await waitFor(() => { + expect(screen.queryByTestId('pane-content-two')).not.toBeInTheDocument() + expect(screen.getByTestId('pane-content-one')).toBeVisible() + expect(screen.queryByTestId(/^workbench-pane-title-/)).not.toBeInTheDocument() + }) + }) + + it('uses a dominant center target and small directional targets while dragging', () => { + persistTwoPaneLayout() + render() + + act(() => { + window.dispatchEvent( + new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, { + detail: { paneKey: paneThreeKey }, + }) + ) + }) + + const targets = screen.getAllByTestId(/^workbench-pane-drop-targets-/) + expect(targets).toHaveLength(2) + expect(screen.getAllByTestId(/drop:.*:center/)[0].className).toContain('bg-background/95') + expect(targets[0].className).toContain('grid-cols-[14%_minmax(0,1fr)_14%]') + }) + + it('places a sidebar task on a pane edge', async () => { + const split = persistTwoPaneLayout() + const targetPaneId = collectWorkbenchPanes(split.root)[1].id + render() + + act(() => { + window.dispatchEvent( + new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, { + detail: { paneKey: paneThreeKey }, + }) + ) + }) + const actualTarget = screen.getByTestId(`drop:${targetPaneId}:right`) + vi.spyOn(document, 'elementsFromPoint').mockReturnValue([actualTarget]) + const detail: WorkbenchSidebarPaneDragEndData = { + paneKey: paneThreeKey, + clientX: 10, + clientY: 10, + handled: false, + } + + act(() => { + window.dispatchEvent(new CustomEvent(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, { detail })) + }) + + await waitFor(() => expect(screen.getByTestId('pane-content-three')).toBeVisible()) + expect(detail.handled).toBe(true) + expect(screen.getAllByTestId(/^workbench-pane-title-/)).toHaveLength(3) + }) + + it('replaces a startup blank identity without retaining it', async () => { + const view = render() + expect(screen.getByTestId('pane-content-blank')).toBeVisible() + + view.rerender() + + await waitFor(() => { + expect(screen.queryByTestId('pane-content-blank')).not.toBeInTheDocument() + expect(screen.getByTestId('pane-content-one')).toBeVisible() + }) + }) +}) diff --git a/wework/src/components/layout/workbenchPaneStack.tsx b/wework/src/components/layout/workbenchPaneStack.tsx index bd33270d237..c426bab1717 100644 --- a/wework/src/components/layout/workbenchPaneStack.tsx +++ b/wework/src/components/layout/workbenchPaneStack.tsx @@ -1,143 +1,843 @@ -/* eslint-disable react-hooks/refs -- Pane identities are cached intentionally so live UI resources survive conversation switches. */ -/* eslint-disable react-refresh/only-export-components -- The active-pane hook belongs to this stack context. */ -import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode } from 'react' +/* eslint-disable react-hooks/refs -- Pane hosts intentionally preserve live task sessions across layout changes. */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { + Columns2, + Focus, + PanelBottom, + PanelLeft, + PanelRight, + PanelTop, + Square, + X, +} from 'lucide-react' +import { Group, Panel, Separator } from 'react-resizable-panels' +import { ActionMenu } from '@/components/common/ActionMenu' +import { TitlebarTooltip } from '@/components/topnav/TitlebarTooltip' +import { WorkbenchSplitActionsPortal } from '@/components/topnav/TitlebarActionsPortal' +import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' +import { + WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, + WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, + type WorkbenchSidebarPaneDragData, + type WorkbenchSidebarPaneDragEndData, +} from './workbenchPaneDrag' import { getWorkbenchPaneKey, type WorkbenchPaneIdentity } from './workbenchPaneIdentity' +import { + WorkbenchPaneHost, + WorkbenchPanePortal, + type WorkbenchPanePresentation, +} from './workbenchPanePresentation' +import styles from './workbenchPaneStack.module.css' +import { + closeWorkbenchPane, + collectWorkbenchPaneKeys, + collectWorkbenchPanes, + createWorkbenchLayout, + findWorkbenchPane, + focusWorkbenchPane, + openWorkbenchPane, + parsePersistedWorkbenchLayout, + placeWorkbenchTask, + pruneWorkbenchLayout, + serializeWorkbenchLayout, + splitWorkbenchPane, + updateWorkbenchSplitSizes, + type WorkbenchLayoutNode, + type WorkbenchLayoutState, + type WorkbenchPaneNode, + type WorkbenchSplitDirection, + type WorkbenchSplitNode, +} from './workbenchSplitLayout' -interface CachedWorkbenchPaneStackProps { +const MIN_PANE_WIDTH = 320 +const MIN_PANE_HEIGHT = 240 + +interface SplitWorkbenchPaneStackProps { activePane: WorkbenchPaneIdentity - maxPanes: number - pinnedKeys: string[] - prunedKeys: string[] + storageKey: string validRuntimeKeys: string[] + retainedResourceKeys: string[] + runtimeKeysReady: boolean activeTestId: string + workbenchVisible: boolean + resolvePane: (paneKey: string) => WorkbenchPaneIdentity | null + getPaneTitle: (pane: WorkbenchPaneIdentity) => string + onPaneFocus: (pane: WorkbenchPaneIdentity) => void + onSplitModeChange?: (split: boolean) => void renderPane: (pane: WorkbenchPaneIdentity) => ReactNode } -export function CachedWorkbenchPaneStack({ +interface DropTarget { + paneId: string + position: 'center' | WorkbenchSplitDirection +} + +interface LayoutLabels { + paneMenu: string + splitLeft: string + splitRight: string + splitUp: string + splitDown: string + closePane: string + focusTask: string + restoreSplit: string + emptyTitle: string + emptyDescription: string + resize: string + dropLeft: string + dropRight: string + dropUp: string + dropDown: string + dropCenter: string +} + +export function SplitWorkbenchPaneStack({ activePane, - maxPanes, - pinnedKeys, - prunedKeys, + storageKey, validRuntimeKeys, + retainedResourceKeys = [], + runtimeKeysReady, activeTestId, + workbenchVisible, + resolvePane, + getPaneTitle, + onPaneFocus, + onSplitModeChange, renderPane, -}: CachedWorkbenchPaneStackProps) { +}: SplitWorkbenchPaneStackProps) { + const { t } = useTranslation() const activeKey = getWorkbenchPaneKey(activePane) const paneCacheRef = useRef(new Map()) - const [cachedKeys, setCachedKeys] = useState(() => [activeKey]) - const cachedKeysRef = useRef(cachedKeys) - const recentKeysRef = useRef([activeKey]) - const pinnedKeySet = useMemo(() => new Set(pinnedKeys), [pinnedKeys]) - const validRuntimeKeySet = useMemo(() => new Set(validRuntimeKeys), [validRuntimeKeys]) - const prunedKeySet = useMemo( - () => - new Set([ - ...prunedKeys.filter(key => key !== activeKey), - ...cachedKeysRef.current.filter( - key => key !== activeKey && key.startsWith('runtime:') && !validRuntimeKeySet.has(key) - ), - ]), - [activeKey, prunedKeys, validRuntimeKeySet] - ) - cachedKeysRef.current = cachedKeys + const paneHostCacheRef = useRef(new Map()) + const appliedActiveKeyRef = useRef(activeKey) + const sidebarDragActiveRef = useRef(false) paneCacheRef.current.set(activeKey, activePane) - recentKeysRef.current = markRecentlyUsed(recentKeysRef.current, activeKey) + + const [layout, setLayout] = useState(() => { + const restored = + typeof window === 'undefined' + ? null + : parsePersistedWorkbenchLayout(window.localStorage.getItem(storageKey)) + return restored ?? createWorkbenchLayout(activeKey) + }) + const [draggedPaneKey, setDraggedPaneKey] = useState(null) + const [dropTarget, setDropTarget] = useState(null) + const [focusedViewPaneId, setFocusedViewPaneId] = useState(null) + + const labels: LayoutLabels = { + paneMenu: t('workbench.split_pane_menu', '分屏操作'), + splitLeft: t('workbench.split_left', '向左分屏'), + splitRight: t('workbench.split_right', '向右分屏'), + splitUp: t('workbench.split_up', '向上分屏'), + splitDown: t('workbench.split_down', '向下分屏'), + closePane: t('workbench.close_split_pane', '关闭分屏'), + focusTask: t('workbench.focus_split_task', '单独查看此任务'), + restoreSplit: t('workbench.restore_split_view', '返回分屏'), + emptyTitle: t('workbench.split_empty_title', '选择一个任务'), + emptyDescription: t( + 'workbench.split_empty_description', + '从左侧任务列表拖入一个任务,或点击任务在此处打开' + ), + resize: t('workbench.resize_split_panes', '调整分屏大小'), + dropLeft: t('workbench.drop_left', '左侧'), + dropRight: t('workbench.drop_right', '右侧'), + dropUp: t('workbench.drop_up', '上方'), + dropDown: t('workbench.drop_down', '下方'), + dropCenter: t('workbench.drop_center', '当前区域'), + } + + const resolveCachedPane = useCallback( + (paneKey: string) => { + const cached = paneCacheRef.current.get(paneKey) + if (cached) return cached + const resolved = resolvePane(paneKey) + if (resolved) paneCacheRef.current.set(paneKey, resolved) + return resolved + }, + [resolvePane] + ) + + const getPaneHost = useCallback((paneKey: string) => { + const cached = paneHostCacheRef.current.get(paneKey) + if (cached) return cached + if (typeof document === 'undefined') return null + const host = document.createElement('div') + host.className = 'h-full min-h-0 min-w-0' + host.dataset.workbenchPaneHost = paneKey + paneHostCacheRef.current.set(paneKey, host) + return host + }, []) + + useLayoutEffect(() => { + if (appliedActiveKeyRef.current === activeKey) return + appliedActiveKeyRef.current = activeKey + setLayout(current => openWorkbenchPane(current, activeKey)) + }, [activeKey]) useEffect(() => { - setCachedKeys(previousKeys => { - const retainedKeys = previousKeys.filter(key => !prunedKeySet.has(key)) - const nextKeys = getStableCachedPaneKeys( - retainedKeys, - activeKey, - maxPanes, - recentKeysRef.current.filter(key => !prunedKeySet.has(key)), - pinnedKeySet - ) - if (sameKeys(previousKeys, nextKeys)) return previousKeys - paneCacheRef.current.forEach((_, key) => { - if (!nextKeys.includes(key)) paneCacheRef.current.delete(key) - }) - recentKeysRef.current = recentKeysRef.current.filter(key => nextKeys.includes(key)) - return nextKeys + if (!runtimeKeysReady) return + const validKeys = new Set([...validRuntimeKeys, activeKey]) + // Runtime hydration is external state; the persisted layout must discard tasks deleted elsewhere. + // eslint-disable-next-line react-hooks/set-state-in-effect + setLayout( + current => pruneWorkbenchLayout(current, validKeys) ?? createWorkbenchLayout(activeKey) + ) + }, [activeKey, runtimeKeysReady, validRuntimeKeys]) + + useEffect(() => { + window.localStorage.setItem(storageKey, serializeWorkbenchLayout(layout)) + }, [layout, storageKey]) + + const panes = collectWorkbenchPanes(layout.root) + const splitMode = panes.length > 1 + const layoutPaneKeys = collectWorkbenchPaneKeys(layout.root) + const retainedPaneKeys = retainedResourceKeys.filter( + key => + !layoutPaneKeys.includes(key) && + (!key.startsWith('runtime:') || validRuntimeKeys.includes(key)) + ) + const mountedPaneKeys = [...layoutPaneKeys, ...retainedPaneKeys] + const mountedPaneKeySignature = mountedPaneKeys.join('\u0000') + const focusedViewPane = + focusedViewPaneId === null ? null : findWorkbenchPane(layout.root, focusedViewPaneId) + const effectiveFocusedViewPaneId = focusedViewPane?.id ?? null + const visibleRoot = focusedViewPane ?? layout.root + const presentations = collectPanePresentations( + layout, + workbenchVisible, + effectiveFocusedViewPaneId, + retainedPaneKeys + ) + + useEffect(() => { + const retainedKeys = new Set( + mountedPaneKeySignature ? mountedPaneKeySignature.split('\u0000') : [] + ) + paneHostCacheRef.current.forEach((host, paneKey) => { + if (retainedKeys.has(paneKey)) return + host.remove() + paneHostCacheRef.current.delete(paneKey) + paneCacheRef.current.delete(paneKey) }) - }, [activeKey, maxPanes, pinnedKeySet, prunedKeySet]) - - const renderKeys = getStableCachedPaneKeys( - cachedKeys.filter(key => !prunedKeySet.has(key)), - activeKey, - maxPanes, - recentKeysRef.current.filter(key => !prunedKeySet.has(key)), - pinnedKeySet + }, [mountedPaneKeySignature]) + + useEffect(() => { + onSplitModeChange?.(splitMode) + }, [onSplitModeChange, splitMode]) + + useEffect(() => { + if (!effectiveFocusedViewPaneId) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) setFocusedViewPaneId(null) + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [effectiveFocusedViewPaneId]) + + const focusPane = useCallback( + (paneId: string) => { + const node = findWorkbenchPane(layout.root, paneId) + if (!node?.paneKey) return + const pane = resolveCachedPane(node.paneKey) + if (!pane) return + setLayout(current => focusWorkbenchPane(current, paneId)) + onPaneFocus(pane) + }, + [layout.root, onPaneFocus, resolveCachedPane, setLayout] ) + const closePane = useCallback( + (paneId: string) => { + setFocusedViewPaneId(current => (current === paneId ? null : current)) + setLayout(current => { + const next = closeWorkbenchPane(current, paneId) + const nextNode = findWorkbenchPane(next.root, next.focusedPaneId) + const nextPane = nextNode?.paneKey ? resolveCachedPane(nextNode.paneKey) : null + if (nextPane) queueMicrotask(() => onPaneFocus(nextPane)) + return next + }) + }, + [onPaneFocus, resolveCachedPane, setFocusedViewPaneId, setLayout] + ) + + const splitPane = useCallback( + (paneId: string, direction: WorkbenchSplitDirection) => { + setFocusedViewPaneId(null) + setLayout(current => splitWorkbenchPane(current, paneId, direction)) + }, + [setFocusedViewPaneId, setLayout] + ) + + const placeTask = useCallback( + (paneKey: string, target: DropTarget) => { + const pane = resolveCachedPane(paneKey) + if (!pane) return + setFocusedViewPaneId(null) + setLayout(current => placeWorkbenchTask(current, paneKey, target.paneId, target.position)) + queueMicrotask(() => onPaneFocus(pane)) + }, + [onPaneFocus, resolveCachedPane, setFocusedViewPaneId, setLayout] + ) + + useEffect(() => { + const readDropTarget = (clientX: number, clientY: number): DropTarget | null => { + const element = document + .elementsFromPoint(clientX, clientY) + .map(candidate => + candidate.closest('[data-workbench-pane-drop-target="true"]') + ) + .find((candidate): candidate is HTMLElement => candidate !== null) + const paneId = element?.dataset.workbenchPaneId + const position = element?.dataset.workbenchPaneDropPosition as + | DropTarget['position'] + | undefined + return paneId && position ? { paneId, position } : null + } + const handleDragStart = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail?.paneKey) return + sidebarDragActiveRef.current = true + setDraggedPaneKey(detail.paneKey) + } + const handleDragMove = (event: PointerEvent) => { + if (!sidebarDragActiveRef.current) return + setDropTarget(readDropTarget(event.clientX, event.clientY)) + } + const handleDragEnd = (event: Event) => { + const detail = (event as CustomEvent).detail + sidebarDragActiveRef.current = false + const target = detail ? readDropTarget(detail.clientX, detail.clientY) : null + if (detail && target) { + detail.handled = true + placeTask(detail.paneKey, target) + } + setDraggedPaneKey(null) + setDropTarget(null) + } + window.addEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, handleDragStart) + window.addEventListener('pointermove', handleDragMove, true) + window.addEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handleDragEnd) + return () => { + window.removeEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, handleDragStart) + window.removeEventListener('pointermove', handleDragMove, true) + window.removeEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handleDragEnd) + } + }, [placeTask]) + return ( -
- {renderKeys.map(key => { - const pane = paneCacheRef.current.get(key) - if (!pane) return null - const active = key === activeKey + <> + {presentations.map(({ paneKey, ...presentation }) => { + const pane = resolveCachedPane(paneKey) + const host = getPaneHost(paneKey) + if (!pane || !host) return null return ( - - - + ) })} -
+ {!splitMode && panes[0] ? ( + + + + ) : null} +
+ + setFocusedViewPaneId(current => (current === paneId ? null : paneId)) + } + onSizesChanged={(splitId, sizes) => + setLayout(current => updateWorkbenchSplitSizes(current, splitId, sizes)) + } + /> +
+ ) } -const WorkbenchPaneActiveContext = createContext(true) +function collectPanePresentations( + layout: WorkbenchLayoutState, + workbenchVisible: boolean, + focusedViewPaneId: string | null, + retainedPaneKeys: string[] +): Array { + const split = collectWorkbenchPanes(layout.root).length > 1 + const visiblePresentations = collectWorkbenchPanes(layout.root).flatMap(pane => { + if (!pane.paneKey) return [] + const visible = focusedViewPaneId === null || focusedViewPaneId === pane.id + return [ + { + paneKey: pane.paneKey, + paneId: pane.id, + visible: workbenchVisible && visible, + focused: visible && layout.focusedPaneId === pane.id, + headerActionsPortalId: split ? `workbench-pane-header-actions-${pane.id}` : null, + }, + ] + }) + return [ + ...visiblePresentations, + ...retainedPaneKeys.map(paneKey => ({ + paneKey, + paneId: `retained:${paneKey}`, + visible: false, + focused: false, + headerActionsPortalId: null, + })), + ] +} + +function WorkbenchLayoutRenderer({ + node, + canonicalLayout, + totalPanes, + activeTestId, + dragged, + dropTarget, + resolvePane, + getPaneTitle, + getPaneHost, + labels, + focusedViewPaneId, + onFocus, + onClose, + onSplit, + onToggleFocusedView, + onSizesChanged, +}: { + node: WorkbenchLayoutNode + canonicalLayout: WorkbenchLayoutState + totalPanes: number + activeTestId: string + dragged: boolean + dropTarget: DropTarget | null + resolvePane: (paneKey: string) => WorkbenchPaneIdentity | null + getPaneTitle: (pane: WorkbenchPaneIdentity) => string + getPaneHost: (paneKey: string) => HTMLDivElement | null + labels: LayoutLabels + focusedViewPaneId: string | null + onFocus: (paneId: string) => void + onClose: (paneId: string) => void + onSplit: (paneId: string, direction: WorkbenchSplitDirection) => void + onToggleFocusedView: (paneId: string) => void + onSizesChanged: (splitId: string, sizes: Record) => void +}) { + if (node.type === 'pane') { + return ( + + ) + } + + return ( + [ + child.id, + node.sizes[index] ?? 100 / node.children.length, + ]) + )} + onLayoutChanged={(sizes, meta) => { + if (meta.isUserInteraction) onSizesChanged(node.id, sizes) + }} + className="relative h-full w-full" + > + {node.children.map((child, index) => ( + + + + ))} + + ) +} -export function useWorkbenchPaneActive() { - return useContext(WorkbenchPaneActiveContext) +function LayoutPanel({ + child, + index, + split, + labels, + children, +}: { + child: WorkbenchLayoutNode + index: number + split: WorkbenchSplitNode + labels: LayoutLabels + children: ReactNode +}) { + return ( + <> + {index > 0 ? ( + + ) : null} + + {children} + + + ) } -const CachedWorkbenchPane = memo(function CachedWorkbenchPane({ +function WorkbenchPaneView({ pane, - renderPane, + focused, + focusedView, + totalPanes, + activeTestId, + dragged, + dropTarget, + resolvePane, + getPaneTitle, + getPaneHost, + labels, + onFocus, + onClose, + onSplit, + onToggleFocusedView, }: { - pane: WorkbenchPaneIdentity - renderPane: (pane: WorkbenchPaneIdentity) => ReactNode + pane: WorkbenchPaneNode + focused: boolean + focusedView: boolean + totalPanes: number + activeTestId: string + dragged: boolean + dropTarget: DropTarget | null + resolvePane: (paneKey: string) => WorkbenchPaneIdentity | null + getPaneTitle: (pane: WorkbenchPaneIdentity) => string + getPaneHost: (paneKey: string) => HTMLDivElement | null + labels: LayoutLabels + onFocus: (paneId: string) => void + onClose: (paneId: string) => void + onSplit: (paneId: string, direction: WorkbenchSplitDirection) => void + onToggleFocusedView: (paneId: string) => void }) { - return <>{renderPane(pane)} -}) - -function getStableCachedPaneKeys( - keys: string[], - activeKey: string, - maxPanes: number, - recentKeys: string[], - pinnedKeys: ReadonlySet -) { - let nextKeys = keys.includes(activeKey) ? keys : [...keys, activeKey] - const maxCount = Math.max(1, maxPanes) + nextKeys.filter(key => pinnedKeys.has(key)).length - while (nextKeys.length > maxCount) { - const evictKey = - recentKeys.find(key => key !== activeKey && !pinnedKeys.has(key) && nextKeys.includes(key)) ?? - nextKeys.find(key => key !== activeKey && !pinnedKeys.has(key)) - if (!evictKey) break - nextKeys = nextKeys.filter(key => key !== evictKey) - } - return nextKeys + const identity = pane.paneKey ? resolvePane(pane.paneKey) : null + const title = identity ? getPaneTitle(identity) : labels.emptyTitle + const host = pane.paneKey ? getPaneHost(pane.paneKey) : null + return ( +
{ + if (!focused) onFocus(pane.id) + }} + > + {totalPanes > 1 ? ( +
+ +
+ + + + 1} + labels={labels} + onSplit={onSplit} + onClose={onClose} + /> + + + +
+ ) : null} +
+ {host ? ( +
+ +
+ ) : ( +
+

{labels.emptyTitle}

+

{labels.emptyDescription}

+
+ )} +
+ {dragged ? ( + + ) : null} +
+ ) } -function markRecentlyUsed(keys: string[], activeKey: string) { - return [...keys.filter(key => key !== activeKey), activeKey] +function WorkbenchPaneMenu({ + paneId, + canClose, + labels, + onSplit, + onClose, +}: { + paneId: string + canClose: boolean + labels: LayoutLabels + onSplit: (paneId: string, direction: WorkbenchSplitDirection) => void + onClose: (paneId: string) => void +}) { + return ( + onSplit(paneId, 'left'), + }, + { + label: labels.splitRight, + icon: PanelRight, + testId: `workbench-split-right-${paneId}`, + onSelect: () => onSplit(paneId, 'right'), + }, + { + label: labels.splitUp, + icon: PanelTop, + testId: `workbench-split-up-${paneId}`, + onSelect: () => onSplit(paneId, 'up'), + }, + { + label: labels.splitDown, + icon: PanelBottom, + testId: `workbench-split-down-${paneId}`, + onSelect: () => onSplit(paneId, 'down'), + }, + { + label: labels.closePane, + icon: X, + testId: `workbench-close-pane-menu-${paneId}`, + disabled: !canClose, + onSelect: () => onClose(paneId), + }, + ]} + /> + ) } -function sameKeys(left: string[], right: string[]) { - return left.length === right.length && left.every((key, index) => key === right[index]) +function WorkbenchPaneDropTargets({ + paneId, + labels, + activePosition, +}: { + paneId: string + labels: LayoutLabels + activePosition: DropTarget['position'] | null +}) { + return ( +
+ + + + + +
+ ) +} + +function DropArea({ + paneId, + position, + label, + active, + className, +}: { + paneId: string + position: DropTarget['position'] + label: string + active: boolean + className: string +}) { + const Icon = + position === 'left' + ? PanelLeft + : position === 'right' + ? PanelRight + : position === 'up' + ? PanelTop + : position === 'down' + ? PanelBottom + : Square + return ( +
+ + {label} +
+ ) } diff --git a/wework/src/components/layout/workbenchSplitLayout.test.ts b/wework/src/components/layout/workbenchSplitLayout.test.ts new file mode 100644 index 00000000000..df01c1c5322 --- /dev/null +++ b/wework/src/components/layout/workbenchSplitLayout.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { + closeWorkbenchPane, + collectWorkbenchPanes, + createWorkbenchLayout, + focusWorkbenchTask, + openWorkbenchPane, + parsePersistedWorkbenchLayout, + placeWorkbenchTask, + pruneWorkbenchLayout, + serializeWorkbenchLayout, + splitWorkbenchPane, + updateWorkbenchSplitSizes, +} from './workbenchSplitLayout' + +const one = 'runtime:device:one' +const two = 'runtime:device:two' +const three = 'runtime:device:three' + +describe('workbenchSplitLayout', () => { + it('replaces the focused pane when sidebar navigation opens a new task', () => { + const initial = createWorkbenchLayout(one) + const next = openWorkbenchPane(initial, two) + + expect(collectWorkbenchPanes(next.root).map(pane => pane.paneKey)).toEqual([two]) + expect(next.focusedPaneId).toBe(initial.focusedPaneId) + }) + + it('focuses an already visible task without duplicating or moving it', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const next = openWorkbenchPane(split, one) + + expect(collectWorkbenchPanes(next.root).map(pane => pane.paneKey)).toEqual([one, two]) + expect( + collectWorkbenchPanes(next.root).find(pane => pane.id === next.focusedPaneId)?.paneKey + ).toBe(one) + }) + + it('creates one empty pane and fills it on the next sidebar navigation', () => { + const initial = createWorkbenchLayout(one) + const split = splitWorkbenchPane(initial, initial.focusedPaneId, 'right') + + expect(collectWorkbenchPanes(split.root).map(pane => pane.paneKey)).toEqual([one, null]) + + const filled = openWorkbenchPane(split, two) + expect(collectWorkbenchPanes(filled.root).map(pane => pane.paneKey)).toEqual([one, two]) + }) + + it('places a sidebar task on an edge with one task per pane', () => { + const initial = createWorkbenchLayout(one) + const next = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'left') + + expect(collectWorkbenchPanes(next.root).map(pane => pane.paneKey)).toEqual([two, one]) + expect( + collectWorkbenchPanes(next.root).find(pane => pane.id === next.focusedPaneId)?.paneKey + ).toBe(two) + }) + + it('moves an existing task between panes instead of retaining a hidden copy', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const [left, right] = collectWorkbenchPanes(split.root) + const moved = placeWorkbenchTask(split, one, right.id, 'down') + + expect(collectWorkbenchPanes(moved.root).map(pane => pane.paneKey)).toEqual([two, one]) + expect(collectWorkbenchPanes(moved.root).some(pane => pane.id === left.id)).toBe(false) + }) + + it('replaces the target task when dropped in the center', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const right = collectWorkbenchPanes(split.root)[1] + const next = placeWorkbenchTask(split, three, right.id, 'center') + + expect(collectWorkbenchPanes(next.root).map(pane => pane.paneKey)).toEqual([one, three]) + }) + + it('automatically removes a pane when its task closes', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const right = collectWorkbenchPanes(split.root)[1] + const closed = closeWorkbenchPane(split, right.id) + + expect(collectWorkbenchPanes(closed.root).map(pane => pane.paneKey)).toEqual([one]) + }) + + it('keeps one empty root pane when the last task closes', () => { + const initial = createWorkbenchLayout(one) + const closed = closeWorkbenchPane(initial, initial.focusedPaneId) + + expect(collectWorkbenchPanes(closed.root)).toEqual([ + expect.objectContaining({ id: initial.focusedPaneId, paneKey: null }), + ]) + }) + + it('prunes invalid runtime tasks and collapses their split', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const pruned = pruneWorkbenchLayout(split, new Set([one])) + + expect(pruned).not.toBeNull() + expect(collectWorkbenchPanes(pruned!.root).map(pane => pane.paneKey)).toEqual([one]) + }) + + it('preserves an intentionally empty split while runtime tasks are valid', () => { + const initial = createWorkbenchLayout(one) + const split = splitWorkbenchPane(initial, initial.focusedPaneId, 'right') + const pruned = pruneWorkbenchLayout(split, new Set([one])) + + expect(collectWorkbenchPanes(pruned!.root).map(pane => pane.paneKey)).toEqual([one, null]) + }) + + it('persists v2 layouts and rejects the obsolete tabbed v1 shape', () => { + const initial = createWorkbenchLayout(one) + const split = placeWorkbenchTask(initial, two, initial.focusedPaneId, 'right') + const resized = updateWorkbenchSplitSizes(split, split.root.id, { + [collectWorkbenchPanes(split.root)[0].id]: 35, + [collectWorkbenchPanes(split.root)[1].id]: 65, + }) + + expect(parsePersistedWorkbenchLayout(serializeWorkbenchLayout(resized))).toEqual(resized) + expect( + parsePersistedWorkbenchLayout( + JSON.stringify({ version: 1, layout: { version: 1, root: {} } }) + ) + ).toBeNull() + }) + + it('returns null when focusing a task outside the layout', () => { + expect(focusWorkbenchTask(createWorkbenchLayout(one), two)).toBeNull() + }) +}) diff --git a/wework/src/components/layout/workbenchSplitLayout.ts b/wework/src/components/layout/workbenchSplitLayout.ts new file mode 100644 index 00000000000..7433a3cb93b --- /dev/null +++ b/wework/src/components/layout/workbenchSplitLayout.ts @@ -0,0 +1,398 @@ +export type WorkbenchSplitOrientation = 'horizontal' | 'vertical' +export type WorkbenchSplitDirection = 'left' | 'right' | 'up' | 'down' + +export interface WorkbenchSplitNode { + id: string + type: 'split' + orientation: WorkbenchSplitOrientation + children: WorkbenchLayoutNode[] + sizes: number[] +} + +export interface WorkbenchPaneNode { + id: string + type: 'pane' + paneKey: string | null +} + +export type WorkbenchLayoutNode = WorkbenchSplitNode | WorkbenchPaneNode + +export interface WorkbenchLayoutState { + version: 2 + root: WorkbenchLayoutNode + focusedPaneId: string +} + +export interface PersistedWorkbenchLayout { + version: 2 + layout: WorkbenchLayoutState +} + +let layoutIdSequence = 0 + +function createLayoutId(prefix: 'split' | 'pane') { + layoutIdSequence += 1 + return `${prefix}-${Date.now().toString(36)}-${layoutIdSequence.toString(36)}` +} + +function createPane(paneKey: string | null): WorkbenchPaneNode { + return { + id: createLayoutId('pane'), + type: 'pane', + paneKey, + } +} + +export function createWorkbenchLayout(paneKey: string): WorkbenchLayoutState { + const pane = createPane(paneKey) + return { + version: 2, + root: pane, + focusedPaneId: pane.id, + } +} + +export function collectWorkbenchPaneKeys(node: WorkbenchLayoutNode): string[] { + if (node.type === 'pane') return node.paneKey ? [node.paneKey] : [] + return node.children.flatMap(collectWorkbenchPaneKeys) +} + +export function collectWorkbenchPanes(node: WorkbenchLayoutNode): WorkbenchPaneNode[] { + if (node.type === 'pane') return [node] + return node.children.flatMap(collectWorkbenchPanes) +} + +export function findWorkbenchPane( + node: WorkbenchLayoutNode, + paneId: string +): WorkbenchPaneNode | null { + if (node.type === 'pane') return node.id === paneId ? node : null + for (const child of node.children) { + const match = findWorkbenchPane(child, paneId) + if (match) return match + } + return null +} + +export function focusWorkbenchPane( + state: WorkbenchLayoutState, + paneId: string +): WorkbenchLayoutState { + return findWorkbenchPane(state.root, paneId) ? { ...state, focusedPaneId: paneId } : state +} + +export function focusWorkbenchTask( + state: WorkbenchLayoutState, + paneKey: string +): WorkbenchLayoutState | null { + const pane = collectWorkbenchPanes(state.root).find(candidate => candidate.paneKey === paneKey) + return pane ? { ...state, focusedPaneId: pane.id } : null +} + +export function openWorkbenchPane( + state: WorkbenchLayoutState, + paneKey: string +): WorkbenchLayoutState { + const existing = focusWorkbenchTask(state, paneKey) + if (existing) return existing + + const panes = collectWorkbenchPanes(state.root) + const target = panes.find(pane => pane.id === state.focusedPaneId) ?? panes[0] + if (!target) return createWorkbenchLayout(paneKey) + return { + ...state, + root: updatePane(state.root, target.id, pane => ({ ...pane, paneKey })), + focusedPaneId: target.id, + } +} + +export function splitWorkbenchPane( + state: WorkbenchLayoutState, + paneId: string, + direction: WorkbenchSplitDirection +): WorkbenchLayoutState { + if (!findWorkbenchPane(state.root, paneId)) return state + return insertWorkbenchPane(state, paneId, direction, createPane(null)) +} + +export function placeWorkbenchTask( + state: WorkbenchLayoutState, + paneKey: string, + targetPaneId: string, + position: 'center' | WorkbenchSplitDirection +): WorkbenchLayoutState { + const target = findWorkbenchPane(state.root, targetPaneId) + if (!target) return state + + const source = collectWorkbenchPanes(state.root).find(pane => pane.paneKey === paneKey) + if (source?.id === targetPaneId && position !== 'center') return state + if (position === 'center') { + if (source?.id === targetPaneId) return focusWorkbenchPane(state, targetPaneId) + + let root = state.root + if (source) { + root = removePane(root, source.id) ?? createPane(null) + } + const remainingTarget = findWorkbenchPane(root, targetPaneId) + if (!remainingTarget) return state + root = updatePane(root, targetPaneId, pane => ({ ...pane, paneKey })) + return { + ...state, + root, + focusedPaneId: targetPaneId, + } + } + + let root = state.root + if (source) { + root = removePane(root, source.id) ?? createPane(null) + } + if (!findWorkbenchPane(root, targetPaneId)) return state + return insertWorkbenchPane( + { + ...state, + root, + focusedPaneId: targetPaneId, + }, + targetPaneId, + position, + createPane(paneKey) + ) +} + +export function closeWorkbenchPane( + state: WorkbenchLayoutState, + paneId: string +): WorkbenchLayoutState { + const panes = collectWorkbenchPanes(state.root) + if (!panes.some(pane => pane.id === paneId)) return state + if (panes.length === 1) { + return { + ...state, + root: updatePane(state.root, paneId, pane => ({ ...pane, paneKey: null })), + focusedPaneId: paneId, + } + } + + const root = removePane(state.root, paneId) + if (!root) return state + const remaining = collectWorkbenchPanes(root) + const nextFocused = + remaining.find(pane => pane.id === state.focusedPaneId) ?? + remaining.find(pane => pane.paneKey) ?? + remaining[0] + return { + ...state, + root, + focusedPaneId: nextFocused.id, + } +} + +export function updateWorkbenchSplitSizes( + state: WorkbenchLayoutState, + splitId: string, + layout: Record +): WorkbenchLayoutState { + return { + ...state, + root: mapLayoutNode(state.root, node => { + if (node.type !== 'split' || node.id !== splitId) return node + return { + ...node, + sizes: normalizeSizes( + node.children.map(child => layout[child.id] ?? 100 / node.children.length) + ), + } + }), + } +} + +export function pruneWorkbenchLayout( + state: WorkbenchLayoutState, + validPaneKeys: ReadonlySet +): WorkbenchLayoutState | null { + const root = pruneNode(state.root, validPaneKeys) + if (!root) return null + const panes = collectWorkbenchPanes(root) + const focused = + panes.find(pane => pane.id === state.focusedPaneId) ?? + panes.find(pane => pane.paneKey) ?? + panes[0] + return { + ...state, + root, + focusedPaneId: focused.id, + } +} + +export function parsePersistedWorkbenchLayout(value: string | null): WorkbenchLayoutState | null { + if (!value) return null + try { + const parsed = JSON.parse(value) as PersistedWorkbenchLayout + if (parsed.version !== 2 || !isLayoutState(parsed.layout)) return null + return parsed.layout + } catch { + return null + } +} + +export function serializeWorkbenchLayout(state: WorkbenchLayoutState): string { + return JSON.stringify({ version: 2, layout: state } satisfies PersistedWorkbenchLayout) +} + +function insertWorkbenchPane( + state: WorkbenchLayoutState, + paneId: string, + direction: WorkbenchSplitDirection, + newPane: WorkbenchPaneNode +): WorkbenchLayoutState { + const orientation: WorkbenchSplitOrientation = + direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical' + const before = direction === 'left' || direction === 'up' + return { + ...state, + root: splitNodeAtPane(state.root, paneId, newPane, orientation, before), + focusedPaneId: newPane.id, + } +} + +function splitNodeAtPane( + node: WorkbenchLayoutNode, + paneId: string, + newPane: WorkbenchPaneNode, + orientation: WorkbenchSplitOrientation, + before: boolean +): WorkbenchLayoutNode { + if (node.type === 'pane') { + if (node.id !== paneId) return node + return { + id: createLayoutId('split'), + type: 'split', + orientation, + children: before ? [newPane, node] : [node, newPane], + sizes: [50, 50], + } + } + + const targetIndex = node.children.findIndex(child => containsPane(child, paneId)) + if (targetIndex < 0) return node + if (node.orientation === orientation) { + const children = [...node.children] + const sizes = [...node.sizes] + const insertionIndex = before ? targetIndex : targetIndex + 1 + const targetSize = sizes[targetIndex] ?? 100 / children.length + children.splice(insertionIndex, 0, newPane) + sizes[targetIndex] = targetSize / 2 + sizes.splice(insertionIndex, 0, targetSize / 2) + return { ...node, children, sizes: normalizeSizes(sizes) } + } + return { + ...node, + children: node.children.map(child => + containsPane(child, paneId) + ? splitNodeAtPane(child, paneId, newPane, orientation, before) + : child + ), + } +} + +function removePane(node: WorkbenchLayoutNode, paneId: string): WorkbenchLayoutNode | null { + if (node.type === 'pane') return node.id === paneId ? null : node + const retained = node.children + .map((child, index) => ({ + child: removePane(child, paneId), + size: node.sizes[index] ?? 100 / node.children.length, + })) + .filter((entry): entry is { child: WorkbenchLayoutNode; size: number } => entry.child !== null) + if (retained.length === 0) return null + if (retained.length === 1) return retained[0].child + return { + ...node, + children: retained.map(entry => entry.child), + sizes: normalizeSizes(retained.map(entry => entry.size)), + } +} + +function pruneNode( + node: WorkbenchLayoutNode, + validPaneKeys: ReadonlySet +): WorkbenchLayoutNode | null { + if (node.type === 'pane') { + if (!node.paneKey || !node.paneKey.startsWith('runtime:') || validPaneKeys.has(node.paneKey)) { + return node + } + return null + } + const retained = node.children + .map((child, index) => ({ + child: pruneNode(child, validPaneKeys), + size: node.sizes[index] ?? 100 / node.children.length, + })) + .filter((entry): entry is { child: WorkbenchLayoutNode; size: number } => entry.child !== null) + if (retained.length === 0) return null + if (retained.length === 1) return retained[0].child + return { + ...node, + children: retained.map(entry => entry.child), + sizes: normalizeSizes(retained.map(entry => entry.size)), + } +} + +function updatePane( + node: WorkbenchLayoutNode, + paneId: string, + update: (pane: WorkbenchPaneNode) => WorkbenchPaneNode +): WorkbenchLayoutNode { + if (node.type === 'pane') return node.id === paneId ? update(node) : node + return { + ...node, + children: node.children.map(child => updatePane(child, paneId, update)), + } +} + +function mapLayoutNode( + node: WorkbenchLayoutNode, + update: (node: WorkbenchLayoutNode) => WorkbenchLayoutNode +): WorkbenchLayoutNode { + const mapped = + node.type === 'split' + ? { ...node, children: node.children.map(child => mapLayoutNode(child, update)) } + : node + return update(mapped) +} + +function containsPane(node: WorkbenchLayoutNode, paneId: string): boolean { + if (node.type === 'pane') return node.id === paneId + return node.children.some(child => containsPane(child, paneId)) +} + +function normalizeSizes(sizes: number[]): number[] { + const total = sizes.reduce((sum, size) => sum + Math.max(0, size), 0) + if (total <= 0) return sizes.map(() => 100 / sizes.length) + return sizes.map(size => (Math.max(0, size) / total) * 100) +} + +function isLayoutState(value: unknown): value is WorkbenchLayoutState { + if (!value || typeof value !== 'object') return false + const state = value as Partial + return state.version === 2 && typeof state.focusedPaneId === 'string' && isLayoutNode(state.root) +} + +function isLayoutNode(value: unknown): value is WorkbenchLayoutNode { + if (!value || typeof value !== 'object') return false + const node = value as Partial + if (typeof node.id !== 'string') return false + if (node.type === 'pane') { + return typeof node.paneKey === 'string' || node.paneKey === null + } + if (node.type !== 'split') return false + return ( + (node.orientation === 'horizontal' || node.orientation === 'vertical') && + Array.isArray(node.children) && + node.children.length >= 2 && + node.children.every(isLayoutNode) && + Array.isArray(node.sizes) && + node.sizes.length === node.children.length && + node.sizes.every(size => typeof size === 'number' && Number.isFinite(size)) + ) +} diff --git a/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx b/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx index 8fa3889418e..09e89a9cd81 100644 --- a/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx +++ b/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx @@ -169,12 +169,15 @@ export const BottomWorkspacePanel = memo(function BottomWorkspacePanel({ className={cn( 'relative flex shrink-0 flex-col overflow-hidden ease-out', showWorkbenchBackground ? 'bg-background/20' : 'bg-background', - resizing ? 'transition-none' : 'transition-[height,opacity,transform] duration-300', + resizing ? 'transition-none' : 'transition-[height] duration-300', open - ? 'pointer-events-auto translate-y-0 border-t border-border opacity-100' - : 'pointer-events-none translate-y-3 border-t border-transparent opacity-0' + ? 'pointer-events-auto border-t border-border' + : 'pointer-events-none border-t border-transparent' )} - style={{ height: open ? height : 0 }} + style={{ + flexBasis: open ? height : 0, + height: open ? height : 0, + }} aria-hidden={!open} > {renderContent && ( diff --git a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx index a178e534e81..c2d11ee0a7a 100644 --- a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx +++ b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx @@ -118,6 +118,7 @@ interface RightWorkspaceReviewState { interface RightWorkspacePanelProps { showWorkbenchBackground?: boolean visible: boolean + renderTabsInAppTitlebar?: boolean expanded?: boolean allowTemporaryChat?: boolean activeView: RightWorkspacePanelView @@ -222,6 +223,7 @@ function RightWorkspaceBrowserPanelSlot({ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ showWorkbenchBackground = false, visible, + renderTabsInAppTitlebar = true, expanded = false, allowTemporaryChat = true, activeView, @@ -273,7 +275,8 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ const visibleTabs = canBrowseFiles ? availableTabs : availableTabs.filter(tab => tab !== 'files') const showTabs = visibleTabs.length > 0 const platform = getPlatform() - const renderTabsInTitlebar = isTauriRuntime() && platform !== 'win' && visible && showTabs + const renderTabsInTitlebar = + renderTabsInAppTitlebar && isTauriRuntime() && platform !== 'win' && visible && showTabs const harnessSessionsById = new Map( harnessSessions.map(session => [session.sessionId, session] as const) ) diff --git a/wework/src/components/layout/workspace-panels/WorkspacePanelActions.tsx b/wework/src/components/layout/workspace-panels/WorkspacePanelActions.tsx index 53ff74a0522..b4e92feab07 100644 --- a/wework/src/components/layout/workspace-panels/WorkspacePanelActions.tsx +++ b/wework/src/components/layout/workspace-panels/WorkspacePanelActions.tsx @@ -273,7 +273,9 @@ export const WorkspacePanelActions = memo(function WorkspacePanelActions({ ) : ( )} - {t('workbench.open_workspace_location')} + + {t('workbench.open_workspace_location')} + { resizeFrameRef.current = null if (panelRef.current) { + panelRef.current.style.flexBasis = `${nextHeight}px` panelRef.current.style.height = `${nextHeight}px` } } @@ -217,6 +218,7 @@ export function useResizableBottomPanel() { const finishResize = () => { cleanupResize() if (panelRef.current) { + panelRef.current.style.flexBasis = `${nextHeight}px` panelRef.current.style.height = `${nextHeight}px` } setHeight(nextHeight) diff --git a/wework/src/components/topnav/TitlebarActionsPortal.tsx b/wework/src/components/topnav/TitlebarActionsPortal.tsx index 011bbd46663..c336ef0e736 100644 --- a/wework/src/components/topnav/TitlebarActionsPortal.tsx +++ b/wework/src/components/topnav/TitlebarActionsPortal.tsx @@ -9,6 +9,7 @@ export const TITLEBAR_ACTIONS_PORTAL_ID = 'titlebar-actions-portal' export const TITLEBAR_FEEDBACK_PORTAL_ID = 'titlebar-feedback-portal' export const TITLEBAR_RIGHT_PANEL_PORTAL_ID = 'titlebar-right-panel-portal' export const WORKBENCH_MAIN_HEADER_PORTAL_ID = 'workbench-main-header-portal' +export const WORKBENCH_SPLIT_ACTIONS_PORTAL_ID = 'workbench-split-actions-portal' interface TitlebarActionsPortalProps { children: ReactNode @@ -66,6 +67,29 @@ export function WorkbenchMainHeaderPortal({ children }: TitlebarActionsPortalPro return portalTarget ? createOwnedPortal(children, portalTarget) : null } +export function WorkbenchSplitActionsPortal({ children }: TitlebarActionsPortalProps) { + const portalTarget = useSyncExternalStore( + subscribeToPortalTarget, + () => document.getElementById(WORKBENCH_SPLIT_ACTIONS_PORTAL_ID), + () => null + ) + + return portalTarget ? createOwnedPortal(children, portalTarget) : null +} + +export function WorkbenchPaneHeaderActionsPortal({ + children, + targetId, +}: TitlebarActionsPortalProps & { targetId: string | null }) { + const portalTarget = useSyncExternalStore( + subscribeToPortalTarget, + () => (targetId ? document.getElementById(targetId) : null), + () => null + ) + + return portalTarget ? createOwnedPortal(children, portalTarget) : null +} + function getPortalTarget() { return document.getElementById(TITLEBAR_ACTIONS_PORTAL_ID) } diff --git a/wework/src/e2e/automation.ts b/wework/src/e2e/automation.ts index 27fa9e4f5ad..a4bfdaa714e 100644 --- a/wework/src/e2e/automation.ts +++ b/wework/src/e2e/automation.ts @@ -629,7 +629,13 @@ function pressDesktopControlPointer(selector: string): string { return element.textContent?.trim() ?? '' } -async function dragDesktopControlElement(command: DesktopControlCommand): Promise { +let activeDesktopControlDrag: { + sourceText: string + target: HTMLElement +} | null = null + +async function startDesktopControlDrag(command: DesktopControlCommand): Promise { + if (activeDesktopControlDrag) throw new Error('A desktop control drag is already active') const element = findDesktopControlElements(command.selector)[0] if (!element) throw new Error(`Unable to find selector "${command.selector}"`) if (!command.target) throw new Error('Drag requires a target selector') @@ -644,8 +650,33 @@ async function dragDesktopControlElement(command: DesktopControlCommand): Promis await waitForDesktopControlTick() dispatchDesktopControlPointerEvent(target, 'pointermove', endOptions) await waitForDesktopControlTick() - dispatchDesktopControlPointerEvent(document, 'pointerup', { ...endOptions, buttons: 0 }) - return element.textContent?.trim() ?? '' + activeDesktopControlDrag = { + sourceText: element.textContent?.trim() ?? '', + target, + } + return activeDesktopControlDrag.sourceText +} + +async function endDesktopControlDrag(command: DesktopControlCommand): Promise { + const activeDrag = activeDesktopControlDrag + if (!activeDrag) throw new Error('No desktop control drag is active') + const target = command.target ? findDesktopControlElements(command.target)[0] : activeDrag.target + if (!target) throw new Error(`Unable to find target selector "${command.target}"`) + const endOptions = { ...desktopControlEventOptions(target), buttons: 1 } + try { + dispatchDesktopControlPointerEvent(document, 'pointermove', endOptions) + dispatchDesktopControlPointerEvent(target, 'pointermove', endOptions) + await waitForDesktopControlTick() + dispatchDesktopControlPointerEvent(document, 'pointerup', { ...endOptions, buttons: 0 }) + return activeDrag.sourceText + } finally { + activeDesktopControlDrag = null + } +} + +async function dragDesktopControlElement(command: DesktopControlCommand): Promise { + await startDesktopControlDrag(command) + return endDesktopControlDrag(command) } function contextMenuDesktopControlElement(selector: string): string { @@ -994,6 +1025,10 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro return dragDesktopControlElement(command) case 'contextMenu': return contextMenuDesktopControlElement(command.selector) + case 'dragStart': + return startDesktopControlDrag(command) + case 'dragEnd': + return endDesktopControlDrag(command) case 'dropFile': return dropDesktopControlFile(command) case 'dropPaths': diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 748fd86cb53..20a5eb59533 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -430,34 +430,41 @@ export function WorkbenchProvider({ window.addEventListener(SHOW_PLUGIN_TRIAL_GUIDE_EVENT, showGuide) return () => window.removeEventListener(SHOW_PLUGIN_TRIAL_GUIDE_EVENT, showGuide) }, [projectChatScopeKey]) + const setDraftInputForScope = useCallback((scopeKey: string, value: string) => { + setDraftInputByScope(current => { + if ((current[scopeKey] ?? '') === value) return current + if (!value && current[scopeKey] === undefined) return current + const next = { ...current } + if (value) next[scopeKey] = value + else delete next[scopeKey] + return next + }) + if (!value.trim()) { + setTrialTemplatesByScope(current => { + if (!current[scopeKey]) return current + const next = { ...current } + delete next[scopeKey] + return next + }) + setTrialPluginNameByScope(current => { + if (!current[scopeKey]) return current + const next = { ...current } + delete next[scopeKey] + return next + }) + setTrialPluginAppByScope(current => { + if (!current[scopeKey]) return current + const next = { ...current } + delete next[scopeKey] + return next + }) + } + }, []) const setDraftInput = useCallback( (value: string) => { - setDraftInputByScope(current => { - if ((current[projectChatScopeKey] ?? '') === value) return current - return { ...current, [projectChatScopeKey]: value } - }) - if (!value.trim()) { - setTrialTemplatesByScope(current => { - if (!current[projectChatScopeKey]) return current - const next = { ...current } - delete next[projectChatScopeKey] - return next - }) - setTrialPluginNameByScope(current => { - if (!current[projectChatScopeKey]) return current - const next = { ...current } - delete next[projectChatScopeKey] - return next - }) - setTrialPluginAppByScope(current => { - if (!current[projectChatScopeKey]) return current - const next = { ...current } - delete next[projectChatScopeKey] - return next - }) - } + setDraftInputForScope(projectChatScopeKey, value) }, - [projectChatScopeKey] + [projectChatScopeKey, setDraftInputForScope] ) const setComposerError = useCallback( (error: string | null) => { @@ -656,14 +663,15 @@ export function WorkbenchProvider({ state.standaloneDeviceId, user, ]) + const stableConsumeQueuedPluginTrial = useStableEvent(consumeQueuedPluginTrial) useEffect(() => { - queueMicrotask(consumeQueuedPluginTrial) - window.addEventListener(PLUGIN_TRIAL_QUEUED_EVENT, consumeQueuedPluginTrial) + queueMicrotask(stableConsumeQueuedPluginTrial) + window.addEventListener(PLUGIN_TRIAL_QUEUED_EVENT, stableConsumeQueuedPluginTrial) return () => { - window.removeEventListener(PLUGIN_TRIAL_QUEUED_EVENT, consumeQueuedPluginTrial) + window.removeEventListener(PLUGIN_TRIAL_QUEUED_EVENT, stableConsumeQueuedPluginTrial) } - }, [consumeQueuedPluginTrial]) + }, [stableConsumeQueuedPluginTrial]) useEffect(() => { const socketClient = resolvedServices.socketClient if (!socketClient) return undefined @@ -2190,6 +2198,7 @@ export function WorkbenchProvider({ const projectChatValue = useMemo( () => ({ scopeKey: projectChatScopeKey, + inputByScope: draftInputByScope, models: conversationModels, skills: skillSelection.skills, selectedModel: modelSelection.selectedModel, @@ -2218,6 +2227,7 @@ export function WorkbenchProvider({ getSelectedModelOptions: modelSelection.getSelectedModelOptions, onBlockedModelSelect: handleBlockedModelSelect, setInput: setDraftInput, + setInputForScope: setDraftInputForScope, setComposerError, setSelectedSkills: skillSelection.setSelectedSkills, toggleSkill: skillSelection.toggleSkill, @@ -2239,6 +2249,7 @@ export function WorkbenchProvider({ attachmentSelection.uploadingFiles, projectChatScopeKey, draftInput, + draftInputByScope, composerError, trialTemplates, trialPluginName, @@ -2262,6 +2273,7 @@ export function WorkbenchProvider({ modelSelection.getSelectedModel, modelSelection.getSelectedModelOptions, setDraftInput, + setDraftInputForScope, setComposerError, skillSelection.selectedSkills, skillSelection.setSelectedSkills, @@ -2272,6 +2284,7 @@ export function WorkbenchProvider({ const paneProjectChatValue = useMemo( () => ({ scopeKey: projectChatScopeKey, + inputByScope: draftInputByScope, models: conversationModels, skills: skillSelection.skills, selectedModel: modelSelection.selectedModel, @@ -2300,6 +2313,7 @@ export function WorkbenchProvider({ getSelectedModelOptions: modelSelection.getSelectedModelOptions, onBlockedModelSelect: handleBlockedModelSelect, setInput: setDraftInput, + setInputForScope: setDraftInputForScope, setComposerError, setSelectedSkills: skillSelection.setSelectedSkills, toggleSkill: skillSelection.toggleSkill, @@ -2321,6 +2335,7 @@ export function WorkbenchProvider({ attachmentSelection.uploadingFiles, projectChatScopeKey, draftInput, + draftInputByScope, composerError, trialTemplates, trialPluginName, @@ -2343,6 +2358,7 @@ export function WorkbenchProvider({ modelSelection.getSelectedModel, modelSelection.getSelectedModelOptions, setDraftInput, + setDraftInputForScope, setComposerError, skillSelection.selectedSkills, skillSelection.setSelectedSkills, @@ -2353,6 +2369,7 @@ export function WorkbenchProvider({ const value: WorkbenchContextValue = { services: resolvedServices, + workspaceTabId, state, isStartupReady, workspaceFileApi, diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 9f3f1fd3d21..8e28ae1f05c 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -156,12 +156,15 @@ export interface RuntimePaneGuidanceResult { export interface WorkbenchContextValue { services: WorkbenchServices + workspaceTabId?: string state: WorkbenchState isStartupReady: boolean workspaceFileApi: WorkspaceFileApi runtimeTaskReminders: RuntimeTaskReminderState cloudWorkStatus: CloudWorkStatus projectChat: { + scopeKey: string + inputByScope: Readonly> models: UnifiedModel[] skills: UnifiedSkill[] selectedModel: UnifiedModel | null @@ -190,6 +193,7 @@ export interface WorkbenchContextValue { getSelectedModelOptions?: () => ModelOptions onBlockedModelSelect: (model: UnifiedModel, message?: string) => void setInput: (value: string) => void + setInputForScope: (scopeKey: string, value: string) => void setComposerError?: (error: string | null) => void setSelectedSkills: (skills: SkillRef[]) => void toggleSkill: (skill: SkillRef) => void diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index ba05aca0056..21cdb574a2c 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -942,6 +942,24 @@ "environment_review_unavailable": "Change review is unavailable", "resize_right_workspace_panel": "Resize right panel", "resize_bottom_workspace_panel": "Resize bottom panel", + "resize_split_panes": "Resize split panes", + "split_pane_menu": "Split pane actions", + "split_left": "Split left", + "split_right": "Split right", + "split_up": "Split up", + "split_down": "Split down", + "drop_left": "Left", + "drop_right": "Right", + "drop_up": "Top", + "drop_down": "Bottom", + "drop_center": "Current pane", + "close_split_pane": "Close split pane", + "close_split_task": "Remove task from split view", + "focus_split_task": "View this task only", + "restore_split_view": "Return to split view", + "split_empty_title": "Select a task", + "split_empty_description": "Open a task from the sidebar to show it here", + "task_fallback_title": "Task {{taskId}}", "add_workspace_item": "Add workspace item", "expand_workspace_panel": "Expand workspace panel", "close_right_workspace_panel": "Close right panel", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index dd68a7903bb..dcf06123825 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -941,6 +941,24 @@ "environment_review_unavailable": "变更审查不可用", "resize_right_workspace_panel": "调整右侧栏宽度", "resize_bottom_workspace_panel": "调整底部栏高度", + "resize_split_panes": "调整分屏大小", + "split_pane_menu": "分屏操作", + "split_left": "向左分屏", + "split_right": "向右分屏", + "split_up": "向上分屏", + "split_down": "向下分屏", + "drop_left": "左侧", + "drop_right": "右侧", + "drop_up": "上方", + "drop_down": "下方", + "drop_center": "当前区域", + "close_split_pane": "关闭分屏", + "close_split_task": "从分屏中移除任务", + "focus_split_task": "单独查看此任务", + "restore_split_view": "返回分屏", + "split_empty_title": "选择一个任务", + "split_empty_description": "从左侧任务列表中打开要在此处显示的任务", + "task_fallback_title": "任务 {{taskId}}", "add_workspace_item": "添加工作项", "expand_workspace_panel": "放大工作栏", "close_right_workspace_panel": "关闭右侧栏", From d03e1acc5eda3d020b5c211a9eb9ad7e452305fd Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 13 Aug 2026 05:27:23 +0800 Subject: [PATCH 2/4] fix(executor): preserve active provider turns --- .../src/runtime_work/handler/collection.rs | 10 +++++++++- executor/src/runtime_work/handler/tests.rs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/executor/src/runtime_work/handler/collection.rs b/executor/src/runtime_work/handler/collection.rs index e77af3b03df..0d482d43876 100644 --- a/executor/src/runtime_work/handler/collection.rs +++ b/executor/src/runtime_work/handler/collection.rs @@ -773,9 +773,17 @@ impl RuntimeWorkRpcHandler { } pub(super) fn is_active_local_task(&self, local_task_id: &str) -> bool { - self.active_turn_cancellations + let has_active_execution = self + .active_turn_cancellations .lock() .expect("active turn cancellation map lock should not be poisoned") + .contains_key(local_task_id); + if has_active_execution { + return true; + } + self.active_codex_turns + .lock() + .expect("active codex turn map lock should not be poisoned") .contains_key(local_task_id) } diff --git a/executor/src/runtime_work/handler/tests.rs b/executor/src/runtime_work/handler/tests.rs index fd256922f80..aa996b833ab 100644 --- a/executor/src/runtime_work/handler/tests.rs +++ b/executor/src/runtime_work/handler/tests.rs @@ -395,6 +395,24 @@ fn stale_execution_cannot_finish_its_replacement() { let _ = fs::remove_file(index_path); } +#[test] +fn provider_turn_keeps_task_active_when_execution_control_settles_first() { + let handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false"); + let execution_id = start_test_execution(&handler, "task-1"); + handler.record_active_codex_turn( + "task-1", + execution_id, + "thread-1".to_owned(), + "turn-1".to_owned(), + ); + + assert!(handler.finish_local_task_execution("task-1", execution_id)); + assert!(handler.is_active_local_task("task-1")); + + handler.clear_active_codex_turn("task-1", execution_id); + assert!(!handler.is_active_local_task("task-1")); +} + #[test] fn unlinked_failed_codex_turn_persists_assistant_error_in_runtime_handle() { let index_path = temp_runtime_work_index_path("persist-failed-assistant"); From 74809849dfb345426d46ab699e24ebfe4762f1d9 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 13 Aug 2026 06:25:29 +0800 Subject: [PATCH 3/4] fix(wework): stabilize desktop lifecycle checks --- .../src/runtime_work/handler/collection.rs | 16 ++++----- executor/src/runtime_work/handler/tests.rs | 34 +++++++++++++++++-- wework/src-tauri/src/local_terminal.rs | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/executor/src/runtime_work/handler/collection.rs b/executor/src/runtime_work/handler/collection.rs index 0d482d43876..399bc8092b2 100644 --- a/executor/src/runtime_work/handler/collection.rs +++ b/executor/src/runtime_work/handler/collection.rs @@ -695,7 +695,13 @@ impl RuntimeWorkRpcHandler { thread_id: String, turn_id: String, ) { - if !self.is_local_task_execution_active(local_task_id, execution_id) { + let current_execution_id = self + .active_turn_cancellations + .lock() + .expect("active turn cancellation map lock should not be poisoned") + .get(local_task_id) + .map(|control| control.execution_id); + if current_execution_id.is_some_and(|current| current != execution_id) { return; } self.active_codex_turns @@ -787,14 +793,6 @@ impl RuntimeWorkRpcHandler { .contains_key(local_task_id) } - fn is_local_task_execution_active(&self, local_task_id: &str, execution_id: u64) -> bool { - self.active_turn_cancellations - .lock() - .expect("active turn cancellation map lock should not be poisoned") - .get(local_task_id) - .is_some_and(|control| control.execution_id == execution_id) - } - pub(super) fn clear_active_request_user_input(&self, local_task_id: &str, execution_id: u64) { let mut active = self .active_request_user_inputs diff --git a/executor/src/runtime_work/handler/tests.rs b/executor/src/runtime_work/handler/tests.rs index aa996b833ab..c5ef4a759a0 100644 --- a/executor/src/runtime_work/handler/tests.rs +++ b/executor/src/runtime_work/handler/tests.rs @@ -396,9 +396,11 @@ fn stale_execution_cannot_finish_its_replacement() { } #[test] -fn provider_turn_keeps_task_active_when_execution_control_settles_first() { +fn provider_turn_registers_when_execution_control_settles_first() { let handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false"); let execution_id = start_test_execution(&handler, "task-1"); + + assert!(handler.finish_local_task_execution("task-1", execution_id)); handler.record_active_codex_turn( "task-1", execution_id, @@ -406,13 +408,41 @@ fn provider_turn_keeps_task_active_when_execution_control_settles_first() { "turn-1".to_owned(), ); - assert!(handler.finish_local_task_execution("task-1", execution_id)); assert!(handler.is_active_local_task("task-1")); handler.clear_active_codex_turn("task-1", execution_id); assert!(!handler.is_active_local_task("task-1")); } +#[test] +fn stale_provider_turn_cannot_replace_the_current_execution() { + let handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false"); + let stale_execution_id = start_test_execution(&handler, "task-1"); + assert!(handler.finish_local_task_execution("task-1", stale_execution_id)); + let current_execution_id = start_test_execution(&handler, "task-1"); + + handler.record_active_codex_turn( + "task-1", + stale_execution_id, + "stale-thread".to_owned(), + "stale-turn".to_owned(), + ); + assert!(handler.active_codex_turn("task-1").is_none()); + + handler.record_active_codex_turn( + "task-1", + current_execution_id, + "current-thread".to_owned(), + "current-turn".to_owned(), + ); + let current_turn = handler + .active_codex_turn("task-1") + .expect("current execution should register its provider turn"); + assert_eq!(current_turn.execution_id, current_execution_id); + assert_eq!(current_turn.thread_id, "current-thread"); + assert_eq!(current_turn.turn_id, "current-turn"); +} + #[test] fn unlinked_failed_codex_turn_persists_assistant_error_in_runtime_handle() { let index_path = temp_runtime_work_index_path("persist-failed-assistant"); diff --git a/wework/src-tauri/src/local_terminal.rs b/wework/src-tauri/src/local_terminal.rs index 39021c6125e..99bb105919a 100644 --- a/wework/src-tauri/src/local_terminal.rs +++ b/wework/src-tauri/src/local_terminal.rs @@ -16,7 +16,7 @@ const TERMINAL_OUTPUT_EVENT: &str = "local-terminal-output"; const TERMINAL_EXIT_EVENT: &str = "local-terminal-exit"; const DEFAULT_UTF8_LANG: &str = "en_US.UTF-8"; const DEFAULT_UTF8_LC_CTYPE: &str = "UTF-8"; -const HARNESS_VERSION_TIMEOUT: Duration = Duration::from_secs(2); +const HARNESS_VERSION_TIMEOUT: Duration = Duration::from_secs(5); const OPEN_CODE_HARNESS_ID: &str = "opencode"; const CLAUDE_CODE_HARNESS_ID: &str = "claude_code"; const KIMI_CODE_HARNESS_ID: &str = "kimi_code"; From addfadd8eaccd220a11b0bac7c84810cec971f93 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 13 Aug 2026 06:50:25 +0800 Subject: [PATCH 4/4] fix(wework): accept autonomous running snapshots --- .../RuntimeTaskLifecycleStore.test.ts | 21 +++++++++++++++++++ .../workbench/runtimeTaskLifecycle/reducer.ts | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/wework/src/features/workbench/runtimeTaskLifecycle/RuntimeTaskLifecycleStore.test.ts b/wework/src/features/workbench/runtimeTaskLifecycle/RuntimeTaskLifecycleStore.test.ts index e45589d4394..f98860d4ca0 100644 --- a/wework/src/features/workbench/runtimeTaskLifecycle/RuntimeTaskLifecycleStore.test.ts +++ b/wework/src/features/workbench/runtimeTaskLifecycle/RuntimeTaskLifecycleStore.test.ts @@ -424,6 +424,27 @@ describe('RuntimeTaskLifecycleStore', () => { expect(store.getTask(address)?.execution.running).toBe(false) }) + test('accepts an executor-confirmed autonomous turn after the previous turn settles', () => { + const store = new RuntimeTaskLifecycleStore('test') + store.syncRuntimeWork(runtimeWork(task({ running: true }))) + store.executorSettled(address) + + store.syncRuntimeWork( + runtimeWork( + task({ + running: true, + status: 'running', + threadStatus: 'active', + turnStatus: 'inProgress', + }) + ) + ) + + const snapshot = store.getTask(address) + expect(snapshot?.execution.running).toBe(true) + expect(snapshot?.derived.shouldShowSidebarRunning).toBe(true) + }) + test('ignores a settled event from an older turn after a newer turn starts', () => { const store = new RuntimeTaskLifecycleStore('test') store.turnStarted(address, 'turn-1') diff --git a/wework/src/features/workbench/runtimeTaskLifecycle/reducer.ts b/wework/src/features/workbench/runtimeTaskLifecycle/reducer.ts index 9562c6b5784..535e877ace7 100644 --- a/wework/src/features/workbench/runtimeTaskLifecycle/reducer.ts +++ b/wework/src/features/workbench/runtimeTaskLifecycle/reducer.ts @@ -11,6 +11,9 @@ export function reduceRuntimeTaskLifecycle( const hasIdentifiedActiveTurn = state.turnPhase === 'streaming' && state.activeTurnId !== null const terminalStatus = isTerminalTaskStatus(event.task.status) const queuedStatus = isQueuedTaskStatus(event.task.status) + const snapshotConfirmsActiveTurn = + snapshotRunning === true && + (isRunningTaskStatus(event.task.threadStatus) || isRunningTaskStatus(event.task.turnStatus)) const settledLocalHarness = snapshotRunning === false && state.turnPhase !== 'streaming' && @@ -21,6 +24,7 @@ export function reduceRuntimeTaskLifecycle( snapshotRunning !== expectedRunning && !queuedStatus && (!terminalStatus || hasIdentifiedActiveTurn) && + !snapshotConfirmsActiveTurn && !settledLocalHarness if (shouldIgnoreStaleSnapshot) return state @@ -201,6 +205,11 @@ function isTerminalTaskStatus(status: string | null | undefined): boolean { ) } +function isRunningTaskStatus(status: string | null | undefined): boolean { + const normalized = status?.replace(/[_-]/g, '').trim().toLowerCase() + return normalized === 'active' || normalized === 'inprogress' || normalized === 'running' +} + function isLocalHarnessRuntime(runtime: string | null | undefined): boolean { return runtime === 'opencode' || runtime === 'claude_code' || runtime === 'kimi_code' }