diff --git a/e2e/browser-panel-drag.spec.ts b/e2e/browser-panel-drag.spec.ts
index e29ef0e1..6c6be41d 100644
--- a/e2e/browser-panel-drag.spec.ts
+++ b/e2e/browser-panel-drag.spec.ts
@@ -77,3 +77,29 @@ test('does not focus the address bar while dragging a browser panel', async () =
await page.mouse.move(grab!.x + 180, grab!.y + 120, { steps: 20 })
await page.mouse.up()
})
+
+test('closes active and inactive new tabs with a single click on their close buttons', async () => {
+ const browser = await page.evaluate(() => window.__cateE2E!.createBrowser(
+ 'cate://newtab',
+ { x: 120, y: 120 },
+ ))
+ const surface = page.locator(`[data-browser-surface="${browser.panelId}"]`)
+ await expect(surface).toHaveAttribute('data-browser-surface-visible', 'true')
+ const newTab = surface.getByRole('button', { name: 'New tab', exact: true })
+ const closeTabs = surface.getByRole('button', { name: 'Close tab', exact: true })
+ await newTab.click()
+ await newTab.click()
+ await expect(closeTabs).toHaveCount(3)
+
+ // Close the active new tab, then the inactive one. Each click must remove
+ // exactly one tab, and the remaining start page must keep its empty address.
+ await expect(closeTabs.nth(2)).toBeEnabled()
+ await closeTabs.nth(2).click()
+ await expect(closeTabs).toHaveCount(2)
+ await expect(surface.locator('input').first()).toHaveValue('')
+ await closeTabs.first().hover()
+ await expect(closeTabs.first()).toBeEnabled()
+ await closeTabs.first().click()
+ await expect(closeTabs).toHaveCount(1)
+ await expect(surface.locator('input').first()).toHaveValue('')
+})
diff --git a/src/main/ipc/fsWatch.test.ts b/src/main/ipc/fsWatch.test.ts
index 82098c06..3b6e85ec 100644
--- a/src/main/ipc/fsWatch.test.ts
+++ b/src/main/ipc/fsWatch.test.ts
@@ -42,8 +42,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
/**
* Wait until a FS_WATCH_EVENT matching `predicate` reaches the window. Calls
- * `poke` between checks — chokidar drops events that fire before its initial
- * scan finishes, so the change under test is re-applied until it's observed.
+ * `poke` between checks — native watcher setup is asynchronous, so the
+ * change under test is re-applied until it is observed.
*/
async function waitForWatchEvent(
predicate: (event: { type: string; path: string }) => boolean,
@@ -53,7 +53,7 @@ async function waitForWatchEvent(
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await poke()
- await sleep(100)
+ await sleep(300)
const hit = sendToWindow.mock.calls.some(
([, channel, event]) =>
channel === FS_WATCH_EVENT && predicate(event as { type: string; path: string }),
@@ -75,7 +75,7 @@ describe('fs watch events for nested paths', () => {
})
afterEach(async () => {
- await watchStop(fakeEvent, root)
+ await watchStop(fakeEvent, root, 'local')
removeAllowedRoot(root, 'local')
await fs.rm(root, { recursive: true, force: true })
})
@@ -99,14 +99,13 @@ describe('fs watch events for nested paths', () => {
await watchStart(fakeEvent, root, 'local')
- let rev = 0
const seen = await waitForWatchEvent(
(event) =>
(event.type === 'update' || event.type === 'create') && event.path === nestedFile,
- () => fs.writeFile(nestedFile, `v${++rev}`, 'utf8'),
+ () => fs.appendFile(nestedFile, 'next\n', 'utf8'),
)
expect(seen).toBe(true)
- })
+ }, 10_000)
test('does not report changes under an excluded folder', async () => {
const excludedDir = path.join(root, 'node_modules', 'pkg')
@@ -120,13 +119,11 @@ describe('fs watch events for nested paths', () => {
// Poke both files; once the marker's event arrives the watcher is provably
// live, so the absence of the excluded file's event is meaningful.
- let rev = 0
const markerSeen = await waitForWatchEvent(
(event) => event.path === markerFile,
async () => {
- rev++
- await fs.writeFile(excludedFile, `v${rev}`, 'utf8')
- await fs.writeFile(markerFile, `v${rev}`, 'utf8')
+ await fs.appendFile(excludedFile, 'next\n', 'utf8')
+ await fs.appendFile(markerFile, 'next\n', 'utf8')
},
)
expect(markerSeen).toBe(true)
@@ -136,5 +133,5 @@ describe('fs watch events for nested paths', () => {
channel === FS_WATCH_EVENT && (event as { path: string }).path === excludedFile,
)
expect(excludedSeen).toBe(false)
- })
+ }, 10_000)
})
diff --git a/src/renderer/panels/BrowserPanel.component.test.tsx b/src/renderer/panels/BrowserPanel.component.test.tsx
index 912925db..2bcbc5ed 100644
--- a/src/renderer/panels/BrowserPanel.component.test.tsx
+++ b/src/renderer/panels/BrowserPanel.component.test.tsx
@@ -15,13 +15,13 @@ vi.mock('./UrlSuggestions', () => ({ UrlSuggestions: () => null }))
vi.mock('./StartPage', () => ({ StartPage: () =>
Start page
}))
vi.mock('./BrowserHistoryPage', () => ({ BrowserHistoryPage: () => }))
vi.mock('./BrowserPasswordManagerPage', () => ({ BrowserPasswordManagerPage: () => null }))
-vi.mock('./BrowserTabStrip', () => ({ BrowserTabStrip: () => }))
vi.mock('./BrowserBookmarksSidebar', () => ({ BrowserBookmarksSidebar: () => null }))
import BrowserPanel from './BrowserPanel'
import { useAppStore } from '../stores/appStore'
import { useBrowserStore } from '../stores/browserStore'
import { useSettingsStore } from '../stores/settingsStore'
+import { useActivePanelStore } from '../lib/activePanel'
const browserControl = vi.fn(async () => ({ ok: true }))
let downloadsChanged: ((payload: {
@@ -49,6 +49,7 @@ beforeEach(() => {
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
+ useActivePanelStore.setState({ activePanelId: null })
useAppStore.setState({
updatePanelTitle: vi.fn(), updateBrowserActiveTabUrl: vi.fn(), updatePanelTabs: vi.fn(),
})
@@ -79,6 +80,43 @@ function mount(tabs = [{ id: 'tab-1', url: 'https://example.test/', title: 'Exam
}
describe('BrowserPanel live webview', () => {
+ it.each(['ready', 'starting'])('closes new tabs and keeps the remaining start page when its guest is %s', (guestState) => {
+ mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }])
+ const firstGuest = host.querySelector('webview') as HTMLElement
+ const methods = installWebviewMethods(firstGuest)
+ methods.getURL.mockReturnValue('about:blank')
+ if (guestState === 'starting') {
+ methods.getURL.mockImplementation(() => { throw new Error('The WebView must be attached to the DOM and the dom-ready event emitted before this method can be called.') })
+ }
+ act(() => (host.querySelector('button[aria-label="New tab"]') as HTMLButtonElement).click())
+
+ const closeButtons = host.querySelectorAll('button[aria-label="Close tab"]')
+ expect(closeButtons).toHaveLength(2)
+ expect(closeButtons[1].disabled).toBe(false)
+ act(() => closeButtons[1].click())
+
+ expect(host.querySelectorAll('button[aria-label="Close tab"]')).toHaveLength(1)
+ expect(host.querySelector('webview')).toBe(firstGuest)
+ expect(host.textContent).toContain('Start page')
+ expect((host.querySelector('input') as HTMLInputElement).value).toBe('')
+ expect(useAppStore.getState().updatePanelTabs).toHaveBeenLastCalledWith(
+ 'workspace-1', 'browser-1', [{ id: 'tab-1', url: 'cate://newtab', title: '' }], 'tab-1',
+ )
+ })
+
+ it('keeps the start page when selecting an inactive new tab and closing it', () => {
+ mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }])
+ act(() => (host.querySelector('button[aria-label="New tab"]') as HTMLButtonElement).click())
+ act(() => (host.querySelector('[title="New Tab · right-click to pin"]') as HTMLElement).click())
+ expect(host.textContent).toContain('Start page')
+ expect((host.querySelector('input') as HTMLInputElement).value).toBe('')
+
+ act(() => (host.querySelector('button[aria-label="Close tab"]') as HTMLButtonElement).click())
+ expect(host.querySelectorAll('button[aria-label="Close tab"]')).toHaveLength(1)
+ expect(host.textContent).toContain('Start page')
+ expect((host.querySelector('input') as HTMLInputElement).value).toBe('')
+ })
+
it('lets users inspect, replace, and reset an agent-set viewport from the menu', () => {
mount()
const controller = portalMocks.registerController.mock.calls[0][1]
@@ -177,6 +215,25 @@ describe('BrowserPanel live webview', () => {
focus.mockRestore()
})
+ it('does not move focus from new-tab chrome into the hidden guest', () => {
+ const frames = new Map()
+ let frameId = 0
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+ frames.set(++frameId, callback)
+ return frameId
+ })
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { frames.delete(id) })
+ useActivePanelStore.setState({ activePanelId: 'browser-1' })
+ mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }])
+ const guestFocus = vi.spyOn(host.querySelector('webview') as HTMLElement, 'focus')
+ act(() => {
+ for (const callback of frames.values()) callback(0)
+ frames.clear()
+ })
+ expect(document.activeElement).toBe(host.querySelector('input'))
+ expect(guestFocus).not.toHaveBeenCalled()
+ })
+
it('shows download progress in a toolbar popover for its guest', () => {
mount()
const webview = host.querySelector('webview') as HTMLElement
diff --git a/src/renderer/panels/BrowserPanel.tsx b/src/renderer/panels/BrowserPanel.tsx
index 1dd8727e..ede3bd0c 100644
--- a/src/renderer/panels/BrowserPanel.tsx
+++ b/src/renderer/panels/BrowserPanel.tsx
@@ -433,7 +433,8 @@ export default function BrowserPanel({
if (!tab) return
activeTabIdRef.current = id
setActiveTabId(id)
- const webview = webviewsByTabRef.current.get(id)
+ // The start page's hidden about:blank guest is only an automation host.
+ const webview = isStartPageUrl(tab.url) ? undefined : webviewsByTabRef.current.get(id)
const url = webview?.getURL() || tab.url
setCurrentUrl(url)
setInputUrl(addressBarValue(url))
@@ -496,7 +497,7 @@ export default function BrowserPanel({
const neighbor = next[Math.min(idx, next.length - 1)]
activeTabIdRef.current = neighbor.id
setActiveTabId(neighbor.id)
- const webview = webviewsByTabRef.current.get(neighbor.id)
+ const webview = isStartPageUrl(neighbor.url) ? undefined : webviewsByTabRef.current.get(neighbor.id)
const url = webview?.getURL() || neighbor.url
setCurrentUrl(url)
setInputUrl(addressBarValue(url))
@@ -753,6 +754,9 @@ export default function BrowserPanel({
const webview = webviewRef.current
if (!webview) return
const frame = requestAnimationFrame(() => {
+ // Start pages keep an invisible guest for automation; user focus belongs
+ // to the address bar and tab controls, never that guest.
+ if (isStartPageUrl(currentUrlRef.current)) return
// Canvas-node focus happens on mousedown, before the drag dead zone has
// armed. Focusing a switches webContents and blurs the host
// window, which cancels that pending drag. Keep focus on the title bar
diff --git a/src/renderer/panels/BrowserTabStrip.test.tsx b/src/renderer/panels/BrowserTabStrip.test.tsx
index 14dc78ba..2ded47a2 100644
--- a/src/renderer/panels/BrowserTabStrip.test.tsx
+++ b/src/renderer/panels/BrowserTabStrip.test.tsx
@@ -124,4 +124,41 @@ describe('BrowserTabStrip', () => {
expect(setPointerCapture).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledWith('tab-1')
})
+
+ it('closes a new tab on the first press after a drag without a compatibility click', () => {
+ const onClose = vi.fn()
+ act(() => root.render(
+ ,
+ ))
+ const strip = host.querySelector('[aria-label="Browser tabs"]') as HTMLDivElement
+ Object.defineProperty(strip, 'scrollWidth', { configurable: true, value: 600 })
+ Object.defineProperty(strip, 'clientWidth', { configurable: true, value: 300 })
+ const pointer = (type: string, clientX: number): Event => Object.assign(
+ new MouseEvent(type, { bubbles: true, button: 0, clientX }),
+ { pointerId: 1 },
+ )
+ act(() => {
+ strip.dispatchEvent(pointer('pointerdown', 120))
+ strip.dispatchEvent(pointer('pointermove', 70))
+ strip.dispatchEvent(pointer('pointerup', 70))
+ })
+
+ const icon = host.querySelectorAll('button[aria-label="Close tab"] svg')[1]
+ act(() => {
+ icon.dispatchEvent(pointer('pointerdown', 100))
+ icon.dispatchEvent(pointer('pointerup', 100))
+ icon.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
+ })
+ expect(onClose).toHaveBeenCalledExactlyOnceWith('tab-2')
+ })
})
diff --git a/src/renderer/panels/BrowserTabStrip.tsx b/src/renderer/panels/BrowserTabStrip.tsx
index 35d9bde8..8d4caf47 100644
--- a/src/renderer/panels/BrowserTabStrip.tsx
+++ b/src/renderer/panels/BrowserTabStrip.tsx
@@ -38,10 +38,10 @@ export function BrowserTabStrip({ tabs, activeTabId, onSelect, onClose, onNewTab
const handlePointerDown = (event: ReactPointerEvent): void => {
if (event.button !== 0) return
- if (event.target instanceof Element && event.target.closest('button')) return
// If a previous drag ended without Chromium emitting its compatibility
// click, a new press is unambiguously a fresh interaction.
suppressNextClickRef.current = false
+ if (event.target instanceof Element && event.target.closest('button')) return
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return
dragRef.current = {
pointerId: event.pointerId,