From ddcc74fd70652b45b3909ffa430ef6f45962fcc5 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Mon, 13 Apr 2026 11:34:24 +0800 Subject: [PATCH] feat(extension): add open-user-tab support --- extension/src/background.ts | 14 ++++++++++++++ extension/src/protocol.ts | 4 ++-- src/browser/page.ts | 5 +++++ src/capabilityRouting.ts | 1 + src/pipeline/registry.ts | 3 ++- src/pipeline/steps/browser.ts | 6 ++++++ src/types.ts | 2 ++ 7 files changed, 32 insertions(+), 3 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 29766cd0c..1d1f37eaf 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -595,6 +595,20 @@ async function handleTabs(cmd: Command, workspace: string): Promise { await chrome.tabs.update(target.id, { active: true }); return { id: cmd.id, ok: true, data: { selected: target.id } }; } + case 'open-user-tab': { + if (!cmd.url || !isSafeNavigationUrl(cmd.url)) { + return { id: cmd.id, ok: false, error: 'Missing or invalid URL' }; + } + // Find a user window that is NOT an automation window + const automationWindowIds = new Set([...automationSessions.values()].map(s => s.windowId)); + const allWindows = await chrome.windows.getAll({ windowTypes: ['normal'] }); + const userWindow = allWindows.find(w => w.id !== undefined && !automationWindowIds.has(w.id)); + if (!userWindow?.id) { + return { id: cmd.id, ok: false, error: 'No existing Chrome window found. Open a Chrome window first.' }; + } + const tab = await chrome.tabs.create({ windowId: userWindow.id, url: cmd.url, active: true }); + return { id: cmd.id, ok: true, data: { tabId: tab.id, windowId: userWindow.id } }; + } default: return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; } diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 381761c24..93f3d529f 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -20,8 +20,8 @@ export interface Command { workspace?: string; /** URL to navigate to (navigate action) */ url?: string; - /** Sub-operation for tabs: list, new, close, select */ - op?: 'list' | 'new' | 'close' | 'select'; + /** Sub-operation for tabs: list, new, close, select, open-user-tab */ + op?: 'list' | 'new' | 'close' | 'select' | 'open-user-tab'; /** Tab index for tabs select/close */ index?: number; /** Cookie domain filter */ diff --git a/src/browser/page.ts b/src/browser/page.ts index 73db6b441..ac6344cbe 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -123,6 +123,11 @@ export class Page extends BasePage { } } + /** Open a URL as a new tab in the user's existing Chrome window (not the automation window). */ + async openUserTab(url: string): Promise { + await sendCommand('tabs', { op: 'open-user-tab', url, ...this._wsOpt() }); + } + async tabs(): Promise { const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() }); return Array.isArray(result) ? result : []; diff --git a/src/capabilityRouting.ts b/src/capabilityRouting.ts index 7be80596c..dd6b31120 100644 --- a/src/capabilityRouting.ts +++ b/src/capabilityRouting.ts @@ -11,6 +11,7 @@ export const BROWSER_ONLY_STEPS = new Set([ 'evaluate', 'intercept', 'tap', + 'open-user-tab', ]); function pipelineNeedsBrowserSession(pipeline: Record[]): boolean { diff --git a/src/pipeline/registry.ts b/src/pipeline/registry.ts index ac52a00f1..fc6e9a450 100644 --- a/src/pipeline/registry.ts +++ b/src/pipeline/registry.ts @@ -6,7 +6,7 @@ import type { IPage } from '../types.js'; // Import core steps -import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js'; +import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate, stepOpenUserTab } from './steps/browser.js'; import { stepFetch } from './steps/fetch.js'; import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js'; import { stepIntercept } from './steps/intercept.js'; @@ -60,3 +60,4 @@ registerStep('limit', stepLimit); registerStep('intercept', stepIntercept); registerStep('tap', stepTap); registerStep('download', stepDownload); +registerStep('open-user-tab', stepOpenUserTab); diff --git a/src/pipeline/steps/browser.ts b/src/pipeline/steps/browser.ts index d827e3642..e8a9bf560 100644 --- a/src/pipeline/steps/browser.ts +++ b/src/pipeline/steps/browser.ts @@ -74,3 +74,9 @@ export async function stepEvaluate(page: IPage | null, params: unknown, data: un } return result; } + +export async function stepOpenUserTab(page: IPage | null, params: unknown, data: unknown, args: Record): Promise { + const url = String(render(params, { args, data })); + await page!.openUserTab?.(url); + return data; +} diff --git a/src/types.ts b/src/types.ts index cb31594ec..11a607c62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,8 @@ export interface IPage { */ setFileInput?(files: string[], selector?: string): Promise; closeWindow?(): Promise; + /** Open a URL as a new tab in the user's existing Chrome window (not the automation window). */ + openUserTab?(url: string): Promise; /** Returns the current page URL, or null if unavailable. */ getCurrentUrl?(): Promise; /** Returns the active tab ID, or undefined if not yet resolved. */