/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { COMPOSER_INPUT, test, expect } from './fixtures'; import type { Page } from '@playwright/test'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; async function openGitChanges(page: Page) { const composer = page.locator(COMPOSER_INPUT); await composer.fill('create review session'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: create review session/)).toBeVisible(); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); await page.getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ }).click(); return page.getByRole('region', { name: 'Git 变更' }); } async function createSession(page: Page, prompt: string) { const composer = page.locator(COMPOSER_INPUT); await composer.fill(prompt); await composer.press('Enter'); await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible(); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000, }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); const expandSidebar = page.getByRole('button', { name: '展开侧边栏' }); if (await expandSidebar.isVisible()) await expandSidebar.click(); const sessionId = await sidebar .locator('[data-session-id]:has([aria-current="page"])') .getAttribute('data-session-id'); expect(sessionId).toBeTruthy(); return { composer, sessionId: sessionId!, sidebar }; } test('a collapsed workbar never flashes during the first send', async ({ window: page, }) => { await page.evaluate(() => { const watch = { visibleRightWorkbar: false }; const inspect = () => { const panel = document.querySelector( '.maka-session-workbar[data-placement="right"]', ); if ( panel && getComputedStyle(panel).display !== 'none' && panel.getBoundingClientRect().width > 0 ) { watch.visibleRightWorkbar = true; } }; const observer = new MutationObserver(inspect); observer.observe(document.body, { subtree: true, childList: true, attributes: true, }); ( window as typeof window & { __makaFirstSendWorkbarWatch?: typeof watch; __makaFirstSendWorkbarWatchStop?: () => void; } ).__makaFirstSendWorkbarWatch = watch; ( window as typeof window & { __makaFirstSendWorkbarWatchStop?: () => void; } ).__makaFirstSendWorkbarWatchStop = () => { inspect(); observer.disconnect(); }; }); const composer = page.locator(COMPOSER_INPUT); await composer.fill('create a session without opening the workbar'); await page.getByRole('button', { name: '发送' }).click(); const expandWorkbar = page.getByRole('button', { name: '展开任务工作栏' }); await expect(expandWorkbar).toBeVisible({ timeout: 20_000, }); const watch = await page.evaluate(() => { const target = window as typeof window & { __makaFirstSendWorkbarWatch?: { visibleRightWorkbar: boolean }; __makaFirstSendWorkbarWatchStop?: () => void; }; target.__makaFirstSendWorkbarWatchStop?.(); return target.__makaFirstSendWorkbarWatch; }); expect(watch?.visibleRightWorkbar, 'the collapsed right workbar stayed hidden').toBe(false); await expandWorkbar.evaluate((button) => button.click()); await expect( page.locator('.maka-session-workbar[data-placement="right"]'), ).toBeVisible(); }); async function waitForCompanionForkId(page: Page, sourceSessionId: string) { let forkId: string | undefined; await expect .poll(async () => { forkId = (await page.evaluate(() => window.maka.sessions.list())).find( (session) => session.id !== sourceSessionId, )?.id; return forkId; }) .not.toBeUndefined(); return forkId!; } async function setRightWorkbarWidth(page: Page, width: number) { const layoutOwner = page.locator('.maka-workbar-layout-vars'); const workbar = page.locator('.maka-session-workbar[data-placement="right"]'); await expect(layoutOwner).toHaveCount(1); await expect(workbar).toBeVisible(); await layoutOwner.evaluate((element, nextWidth) => { (element as HTMLElement).style.setProperty( '--maka-session-workbar-width', `${nextWidth}px`, ); }, width); await expect .poll(async () => (await workbar.boundingBox())?.width) .toBeCloseTo(width, 0); return workbar; } test('narrow right workbar keeps launcher shortcuts and side-chat send button inside', async ({ window: page, }) => { const composer = page.locator(COMPOSER_INPUT); await composer.fill('create narrow workbar session'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: create narrow workbar session/)).toBeVisible(); await page.getByRole('button', { name: '展开任务工作栏' }).click(); const launcher = page.getByRole('list', { name: '打开工具' }); await expect(launcher).toBeVisible(); const workbar = await setRightWorkbarWidth(page, 320); const workbarBox = await workbar.boundingBox(); const shortcutBoxes = await launcher .locator('kbd') .evaluateAll((elements) => elements.map((element) => { const box = element.getBoundingClientRect(); return { left: box.left, right: box.right }; }), ); expect(workbarBox).not.toBeNull(); expect(shortcutBoxes.length).toBeGreaterThan(0); for (const shortcutBox of shortcutBoxes) { expect.soft(shortcutBox.left).toBeGreaterThanOrEqual(workbarBox!.x); expect.soft(shortcutBox.right).toBeLessThanOrEqual(workbarBox!.x + workbarBox!.width); } await page .getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/, }) .click(); const companion = page.locator('.maka-quote-companion'); await expect(companion).toBeVisible(); await setRightWorkbarWidth(page, 320); const sideChatPanel = page .locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]') .filter({ has: companion }); await expect(sideChatPanel).toBeVisible(); await expect .poll(async () => (await sideChatPanel.boundingBox())?.width) .toBeCloseTo(320, 0); const composerCard = companion.locator('.maka-composer-astryx'); // Keep ChatComposer's inner elevation visible. await expect(composerCard).toHaveCSS('overflow', 'visible'); // #3452: Side Chat is a branch of the main conversation, not a second // conversation surface, so its dock rounds like the main dock. Both resolve // Astryx's `--radius-chat`, the same token `ChatMessageBubble` defaults to — // a side-only `--_chat-composer-radius` split the bubble from the dock the // moment the bubbles moved back to that default. Compared against the main // composer rather than a literal so an upstream token change moves both or // fails here. const composerRadii = await page.evaluate(() => { const wrappers = [...document.querySelectorAll('.maka-composer-astryx')]; const inCompanion = (element: Element) => element.closest('.maka-quote-companion') !== null; const effectiveRadius = (element: Element | undefined) => { if (!element) return null; const styles = getComputedStyle(element); return ( styles.getPropertyValue('--_chat-composer-radius').trim() || styles.getPropertyValue('--radius-chat').trim() ); }; return { side: effectiveRadius(wrappers.find(inCompanion)), main: effectiveRadius(wrappers.find((element) => !inCompanion(element))), }; }); expect(composerRadii.side).toBeTruthy(); expect(composerRadii.side).toBe(composerRadii.main); // Match the long model-label pressure from the reported side-chat screenshot // without coupling the fixture's globally useful default model to this test. await companion.locator('.maka-composer-model-chip-text').evaluate((element) => { element.textContent = 'Nemotron 3 Ultra Long Context Model'; }); const sendButton = companion.getByRole('button', { name: '发送' }); await expect(sendButton).toBeVisible(); const [composerBox, sendBox] = await Promise.all([ composerCard.boundingBox(), sendButton.boundingBox(), ]); expect(composerBox).not.toBeNull(); expect(sendBox).not.toBeNull(); expect(sendBox!.x).toBeGreaterThanOrEqual(composerBox!.x); expect(sendBox!.x + sendBox!.width).toBeLessThanOrEqual( composerBox!.x + composerBox!.width, ); }); // #2188: the address field, not the nav buttons, absorbs the column's free // width. The rule reaches into Astryx Toolbar's slot div, which nothing else // pins — an upstream slot-wrapper change would silently regress it. test('browser address field tracks the workbar column width', async ({ window: page }) => { const composer = page.locator(COMPOSER_INPUT); await composer.fill('create browser session'); await composer.press('Enter'); await expect(page.getByText(/Fake backend received: create browser session/)).toBeVisible(); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); await page.getByRole('button', { name: /浏览器.*打开内置浏览器/ }).click(); await expect(page.getByRole('region', { name: '嵌入式浏览器' })).toBeVisible(); const addressInput = page.getByRole('textbox', { name: '浏览器地址' }); const inputWidth = async () => (await addressInput.boundingBox())!.width; // The tab's content lives in the overlay panel next to the workbar frame, // so the width override must land there, not on `.maka-session-workbar`. const setPanelWidth = async (width: number) => { const panel = page.locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]'); await panel.evaluate((element, nextWidth) => { (element as HTMLElement).style.setProperty('--maka-session-workbar-width', `${nextWidth}px`); }, width); await expect.poll(async () => (await panel.boundingBox())?.width).toBeCloseTo(width, 0); }; await setPanelWidth(480); await expect.poll(inputWidth).toBeGreaterThan(250); await setPanelWidth(320); // Fails at a flat width without the slot rule: 480 and 320 would measure the same. await expect.poll(inputWidth).toBeLessThan(220); await expect.poll(inputWidth).toBeGreaterThan(100); }); test('titlebar workbar action restores an existing tool instead of the picker', async ({ gitReviewWindow, }) => { const page = gitReviewWindow.page; const workspaceActions = page.getByRole('toolbar', { name: '工作区辅助操作' }); const panel = await openGitChanges(page); const panelToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first(); const collapseButton = panelToolbar.getByRole('button', { name: '收起任务工作栏' }); await expect(workspaceActions).toHaveCount(0); await expect(collapseButton).toBeVisible(); await expect(workspaceActions.getByRole('button', { name: '打开工作栏工具' })).toHaveCount(0); const activeTab = panelToolbar.getByRole('tab', { selected: true }); await expect(activeTab).toBeVisible(); const [toolbarBox, tabBox, toggleBox] = await Promise.all([ panelToolbar.boundingBox(), activeTab.boundingBox(), collapseButton.boundingBox(), ]); expect(toolbarBox).not.toBeNull(); expect(tabBox).not.toBeNull(); expect(toggleBox).not.toBeNull(); expect( Math.abs(tabBox!.y + tabBox!.height / 2 - (toggleBox!.y + toggleBox!.height / 2)), ).toBeLessThanOrEqual(1); const simulatedCaptionWidth = 80; await page.evaluate((width) => { document.documentElement.style.setProperty( '--maka-titlebar-overlay-right-width', `${width}px`, ); }, simulatedCaptionWidth); await expect .poll(async () => (await collapseButton.boundingBox())?.x) .toBe(toggleBox!.x - simulatedCaptionWidth); const safeAreaToggleBox = await collapseButton.boundingBox(); expect(safeAreaToggleBox).not.toBeNull(); await page.getByRole('button', { name: '打开工作栏标签' }).click(); const picker = page.getByRole('list', { name: '打开工具' }); await expect(picker).toBeVisible(); await collapseButton.click(); const expandButton = workspaceActions.getByRole('button', { name: '展开任务工作栏' }); await expect(expandButton).toBeVisible(); const expandButtonBox = await expandButton.boundingBox(); expect(expandButtonBox).not.toBeNull(); expect(Math.abs(expandButtonBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1); expect(Math.abs(expandButtonBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1); await expandButton.click(); await expect(panel).toBeVisible(); await expect(picker).not.toBeVisible(); const restoredCollapseButton = panelToolbar.getByRole('button', { name: '收起任务工作栏', }); await expect(restoredCollapseButton).toBeVisible(); const restoredToggleBox = await restoredCollapseButton.boundingBox(); expect(restoredToggleBox).not.toBeNull(); expect(Math.abs(restoredToggleBox!.y - safeAreaToggleBox!.y)).toBeLessThanOrEqual(1); expect(Math.abs(restoredToggleBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1); }); test('Git changes re-read the workspace after the app regains focus', async ({ gitReviewWindow, }) => { const panel = await openGitChanges(gitReviewWindow.page); await expect(panel.getByText('新增 4 行')).toBeVisible(); await writeFile(join(gitReviewWindow.projectRoot, 'base.txt'), 'base\nunstaged\nexternal\n'); await gitReviewWindow.page.evaluate(() => window.dispatchEvent(new Event('focus'))); await expect(panel.getByText('新增 5 行')).toBeVisible(); }); test('Terminal ownership follows the active Session and stops the old resource', async ({ window: page, }) => { const { composer, sessionId, sidebar } = await createSession( page, 'create terminal owner session', ); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await page .getByRole('button', { name: /终端.*查看当前任务的终端运行和实时输出/ }) .click(); const terminal = page.getByRole('region', { name: '任务终端' }); await expect(terminal).toBeVisible(); const terminalRef = await terminal.getAttribute('data-terminal-ref'); expect(terminalRef).toBeTruthy(); await expect .poll(async () => (await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId)) .find((update) => update.result.ref === terminalRef) ?.result.status, ) .toBe('running'); await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); await expect(terminal).toHaveCount(0); await expect .poll(async () => (await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId)) .find((update) => update.result.ref === terminalRef) ?.result.status, ) .not.toBe('running'); await composer.fill('create replacement session'); await composer.press('Enter'); await expect(page.getByText('Fake backend received: create replacement session')).toBeVisible(); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); }); test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({ window: page, }) => { const { composer, sessionId, sidebar } = await createSession( page, 'create side chat source session', ); await page.getByRole('button', { name: '展开任务工作栏' }).click(); const openSideChat = page.getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/, }); await openSideChat.click(); const companion = page.locator('.maka-quote-companion'); await expect(companion).toBeVisible(); const firstForkId = await waitForCompanionForkId(page, sessionId); await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0); await page.getByRole('button', { name: '收起任务工作栏' }).click(); await expect(companion).toBeAttached(); await expect(companion).not.toBeVisible(); await expect .poll(async () => (await page.evaluate(() => window.maka.sessions.list())) .some((session) => session.id === firstForkId), ) .toBe(true); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(companion).toBeVisible(); const sideComposer = companion.locator(COMPOSER_INPUT); await sideComposer.fill('inspect this source without changing it'); await sideComposer.press('Enter'); await expect(companion).toContainText( 'Fake backend received: inspect this source without changing it', ); const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first(); const closeActiveSideChat = () => workbarToolbar .getByRole('tab', { selected: true }) .locator('..') .getByRole('button', { name: /^关闭/ }); await closeActiveSideChat().click(); const confirmation = page.getByRole('dialog'); await expect(confirmation).toContainText('这个临时侧边对话会被永久删除'); await confirmation.getByRole('button', { name: '取消' }).click(); await expect(companion).toBeVisible(); await closeActiveSideChat().click(); await confirmation.getByRole('button', { name: '关闭侧边对话' }).click(); await expect(companion).toHaveCount(0); await expect .poll(async () => (await page.evaluate(() => window.maka.sessions.list())) .some((session) => session.id === firstForkId), ) .toBe(false); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); await openSideChat.click(); await expect(companion).toBeVisible(); const secondForkId = await waitForCompanionForkId(page, sessionId); await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); await expect(companion).toHaveCount(0); await expect .poll(async () => (await page.evaluate(() => window.maka.sessions.list())) .some((session) => session.id === secondForkId), ) .toBe(false); await expect(composer).toHaveText(''); });