feat: [WIP]quick panel 优化

This commit is contained in:
hangerye
2025-12-29 21:14:10 +08:00
parent de29a4c967
commit 2045cf148c
78 changed files with 8535 additions and 943 deletions
+7 -1
View File
@@ -39,7 +39,13 @@
"Bash(grep:*)",
"Bash(pnpm -F chrome-mcp-extension run compile:*)",
"Bash(pnpm -F native-server run compile:*)",
"Bash(pnpm vue-tsc:*)"
"Bash(pnpm vue-tsc:*)",
"Bash(pnpm -F chrome-mcp-shared run build:*)",
"Bash(timeout 10 npm run dev:*)",
"Bash(pnpm -F chrome-mcp-extension vue-tsc:*)",
"Bash(timeout:*)",
"Bash(npm pack:*)",
"Bash(ls /Users/hang/code/ai/mcp-chrome/app/chrome-extension/entrypoints/*.content.ts)"
],
"deny": [],
"ask": []
@@ -58,6 +58,9 @@ export const BACKGROUND_MESSAGE_TYPES = {
ELEMENT_MARKER_DELETE: 'element_marker_delete',
ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
ELEMENT_MARKER_START: 'element_marker_start_from_popup',
// Element picker (human-in-the-loop element selection)
ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',
ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',
// Web editor (in-page visual editing)
WEB_EDITOR_TOGGLE: 'web_editor_toggle',
WEB_EDITOR_APPLY: 'web_editor_apply',
@@ -76,6 +79,8 @@ export const BACKGROUND_MESSAGE_TYPES = {
WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',
// Web editor props (Phase 7.1.6 early injection)
WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',
// Web editor props - open source file in VSCode
WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',
// Quick Panel <-> AgentChat integration
QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',
QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',
@@ -163,6 +168,14 @@ export const TOOL_MESSAGE_TYPES = {
COLLECT_VARIABLES: 'collectVariables',
// Element marker overlay control (content-side)
ELEMENT_MARKER_START: 'element_marker_start',
// Element picker (tool-driven, background <-> content scripts)
ELEMENT_PICKER_START: 'elementPickerStart',
ELEMENT_PICKER_STOP: 'elementPickerStop',
ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',
ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',
ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',
ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',
ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',
} as const;
// Type unions for type safety
@@ -103,13 +103,18 @@ async function toggleQuickPanelInActiveTab(): Promise<void> {
* Initialize Quick Panel keyboard command listener
*/
export function initQuickPanelCommands(): void {
console.log('outside initQuickPanelCommands');
console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
chrome.commands.onCommand.addListener(async (command) => {
console.log('initQuickPanelCommands===>');
if (command !== COMMAND_KEY) return;
console.log(`${LOG_PREFIX} onCommand received:`, command);
if (command !== COMMAND_KEY) {
console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
return;
}
console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
try {
await toggleQuickPanelInActiveTab();
console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
} catch (err) {
console.error(`${LOG_PREFIX} Command handler error:`, err);
}
@@ -29,6 +29,8 @@ import { createCommandTriggerHandlerFactory } from './engine/triggers/command-tr
import { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger';
import { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger';
import { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger';
import { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger';
import { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger';
import { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger';
import { createChromeArtifactService } from './engine/kernel/artifacts';
@@ -356,6 +358,8 @@ export async function bootstrapV3(): Promise<V3Runtime> {
contextMenu: createContextMenuTriggerHandlerFactory({ logger }),
dom: createDomTriggerHandlerFactory({ logger }),
cron: createCronTriggerHandlerFactory({ logger, now }),
interval: createIntervalTriggerHandlerFactory({ logger }),
once: createOnceTriggerHandlerFactory({ logger }),
manual: createManualTriggerHandlerFactory({ logger }),
},
now,
@@ -7,7 +7,15 @@ import type { JsonObject, UnixMillis } from './json';
import type { FlowId, TriggerId } from './ids';
/** 触发器类型 */
export type TriggerKind = 'manual' | 'url' | 'cron' | 'command' | 'contextMenu' | 'dom';
export type TriggerKind =
| 'manual'
| 'url'
| 'cron'
| 'interval'
| 'once'
| 'command'
| 'contextMenu'
| 'dom';
/**
* 触发器基础接口
@@ -53,6 +61,20 @@ export type TriggerSpec =
timezone?: string;
})
// Interval 定时触发(固定间隔重复)
| (TriggerSpecBase & {
kind: 'interval';
/** 间隔分钟数,最小为 1 */
periodMinutes: number;
})
// Once 定时触发(指定时间触发一次后自动禁用)
| (TriggerSpecBase & {
kind: 'once';
/** 触发时间戳 (Unix milliseconds) */
whenMs: UnixMillis;
})
// 快捷键触发
| (TriggerSpecBase & {
kind: 'command';
@@ -41,6 +41,8 @@ export interface EnqueueRunDeps {
export interface EnqueueRunInput {
/** Flow ID (必选) */
flowId: FlowId;
/** 起始节点 ID (可选,默认使用 Flow 的 entryNodeId) */
startNodeId?: NodeId;
/** 优先级 (默认 0) */
priority?: number;
/** 最大尝试次数 (默认 1) */
@@ -158,6 +160,14 @@ export async function enqueueRun(
throw new Error(`Flow "${flowId}" not found`);
}
// 验证 startNodeId 存在于 Flow 中
if (input.startNodeId) {
const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId);
if (!nodeExists) {
throw new Error(`startNodeId "${input.startNodeId}" not found in flow "${flowId}"`);
}
}
const ts = now();
const runId = generateRunId();
@@ -174,6 +184,7 @@ export async function enqueueRun(
args: input.args,
trigger: input.trigger,
debug: input.debug,
startNodeId: input.startNodeId,
nextSeq: 0,
};
await deps.storage.runs.save(runRecord);
@@ -195,6 +195,7 @@ export class RpcServer {
},
{
flowId: params?.flowId as FlowId,
startNodeId: params?.startNodeId as NodeId | undefined,
priority: params?.priority as number | undefined,
maxAttempts: params?.maxAttempts as number | undefined,
args: params?.args as JsonObject | undefined,
@@ -982,6 +983,29 @@ export class RpcServer {
return { ...base, cron: raw.cron, timezone } as TriggerSpec;
}
case 'interval': {
if (raw.periodMinutes === undefined || raw.periodMinutes === null) {
throw new Error('trigger.periodMinutes is required for interval triggers');
}
if (typeof raw.periodMinutes !== 'number' || !Number.isFinite(raw.periodMinutes)) {
throw new Error('trigger.periodMinutes must be a finite number');
}
if (raw.periodMinutes < 1) {
throw new Error('trigger.periodMinutes must be >= 1');
}
return { ...base, periodMinutes: raw.periodMinutes } as TriggerSpec;
}
case 'once': {
if (raw.whenMs === undefined || raw.whenMs === null) {
throw new Error('trigger.whenMs is required for once triggers');
}
if (typeof raw.whenMs !== 'number' || !Number.isFinite(raw.whenMs)) {
throw new Error('trigger.whenMs must be a finite number');
}
return { ...base, whenMs: Math.floor(raw.whenMs) } as TriggerSpec;
}
case 'command': {
if (!raw.commandKey || typeof raw.commandKey !== 'string') {
throw new Error('trigger.commandKey is required for command triggers');
@@ -1033,7 +1057,7 @@ export class RpcServer {
default:
throw new Error(
`trigger.kind must be one of: manual, url, cron, command, contextMenu, dom`,
`trigger.kind must be one of: manual, url, cron, interval, once, command, contextMenu, dom`,
);
}
}
@@ -0,0 +1,243 @@
/**
* @fileoverview Interval Trigger Handler (M3.1)
* @description
* 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
*
* 策略:
* - 每个触发器对应一个重复 alarm
* - 使用 delayInMinutes 使首次触发在配置的间隔后
*/
import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
// ==================== Types ====================
type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;
export interface IntervalTriggerHandlerDeps {
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}
interface InstalledIntervalTrigger {
spec: IntervalTriggerSpec;
periodMinutes: number;
version: number;
}
// ==================== Constants ====================
const ALARM_PREFIX = 'rr_v3_interval_';
// ==================== Utilities ====================
/**
* 校验并规范化 periodMinutes
*/
function normalizePeriodMinutes(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('periodMinutes must be a finite number');
}
if (value < 1) {
throw new Error('periodMinutes must be >= 1');
}
return value;
}
/**
* 生成 alarm 名称
*/
function alarmNameForTrigger(triggerId: TriggerId): string {
return `${ALARM_PREFIX}${triggerId}`;
}
/**
* 从 alarm 名称解析 triggerId
*/
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
if (!name.startsWith(ALARM_PREFIX)) return null;
const id = name.slice(ALARM_PREFIX.length);
return id ? (id as TriggerId) : null;
}
// ==================== Handler Implementation ====================
/**
* 创建 interval 触发器处理器工厂
*/
export function createIntervalTriggerHandlerFactory(
deps?: IntervalTriggerHandlerDeps,
): TriggerHandlerFactory<'interval'> {
return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
}
/**
* 创建 interval 触发器处理器
*/
export function createIntervalTriggerHandler(
fireCallback: TriggerFireCallback,
deps?: IntervalTriggerHandlerDeps,
): TriggerHandler<'interval'> {
const logger = deps?.logger ?? console;
const installed = new Map<TriggerId, InstalledIntervalTrigger>();
const versions = new Map<TriggerId, number>();
let listening = false;
/**
* 递增版本号以使挂起的操作失效
*/
function bumpVersion(triggerId: TriggerId): number {
const next = (versions.get(triggerId) ?? 0) + 1;
versions.set(triggerId, next);
return next;
}
/**
* 清除指定 alarm
*/
async function clearAlarmByName(name: string): Promise<void> {
if (!chrome.alarms?.clear) return;
try {
await Promise.resolve(chrome.alarms.clear(name));
} catch (e) {
logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
}
}
/**
* 清除所有 interval alarms
*/
async function clearAllIntervalAlarms(): Promise<void> {
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
try {
const alarms = await Promise.resolve(chrome.alarms.getAll());
const list = Array.isArray(alarms) ? alarms : [];
await Promise.all(
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
);
} catch (e) {
logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
}
}
/**
* 调度 alarm
*/
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
if (!chrome.alarms?.create) {
logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
return;
}
const entry = installed.get(triggerId);
if (!entry || entry.version !== expectedVersion) return;
const name = alarmNameForTrigger(triggerId);
const periodInMinutes = entry.periodMinutes;
try {
// 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
// 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
await Promise.resolve(
chrome.alarms.create(name, {
delayInMinutes: periodInMinutes,
periodInMinutes,
}),
);
} catch (e) {
logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
}
}
/**
* Alarm 事件处理
*/
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
if (!triggerId) return;
const entry = installed.get(triggerId);
if (!entry) return;
// 触发回调
Promise.resolve(
fireCallback.onFire(triggerId, {
sourceTabId: undefined,
sourceUrl: undefined,
}),
).catch((e) => {
logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
});
};
/**
* 确保正在监听 alarm 事件
*/
function ensureListening(): void {
if (listening) return;
if (!chrome.alarms?.onAlarm?.addListener) {
logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
return;
}
chrome.alarms.onAlarm.addListener(onAlarm);
listening = true;
}
/**
* 停止监听 alarm 事件
*/
function stopListening(): void {
if (!listening) return;
try {
chrome.alarms.onAlarm.removeListener(onAlarm);
} catch (e) {
logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
} finally {
listening = false;
}
}
return {
kind: 'interval',
async install(trigger: IntervalTriggerSpec): Promise<void> {
const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);
const version = bumpVersion(trigger.id);
installed.set(trigger.id, {
spec: { ...trigger, periodMinutes },
periodMinutes,
version,
});
ensureListening();
await schedule(trigger.id, version);
},
async uninstall(triggerId: string): Promise<void> {
const id = triggerId as TriggerId;
bumpVersion(id);
installed.delete(id);
await clearAlarmByName(alarmNameForTrigger(id));
if (installed.size === 0) {
stopListening();
}
},
async uninstallAll(): Promise<void> {
for (const id of installed.keys()) {
bumpVersion(id);
}
installed.clear();
await clearAllIntervalAlarms();
stopListening();
},
getInstalledIds(): string[] {
return Array.from(installed.keys());
},
};
}
@@ -0,0 +1,290 @@
/**
* @fileoverview Once Trigger Handler (M3.1)
* @description
* 使用 chrome.alarms 的 when 参数实现一次性定时触发。
*
* 行为:
* - 每个触发器对应一个一次性 alarm
* - 触发后自动将触发器禁用 (enabled=false) 并卸载
*/
import type { UnixMillis } from '../../domain/json';
import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import { createTriggersStore } from '../../storage/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
// ==================== Types ====================
type OnceTriggerSpec = TriggerSpecByKind<'once'>;
export interface OnceTriggerHandlerDeps {
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
/**
* 可选:自定义禁用触发器的方法
* 如果未提供,将直接更新 TriggerStore
*/
disableTrigger?: (triggerId: TriggerId) => Promise<void>;
}
interface InstalledOnceTrigger {
spec: OnceTriggerSpec;
whenMs: UnixMillis;
version: number;
}
// ==================== Constants ====================
const ALARM_PREFIX = 'rr_v3_once_';
// ==================== Utilities ====================
/**
* 校验并规范化 whenMs
*/
function normalizeWhenMs(value: unknown): UnixMillis {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('whenMs must be a finite number');
}
return Math.floor(value) as UnixMillis;
}
/**
* 生成 alarm 名称
*/
function alarmNameForTrigger(triggerId: TriggerId): string {
return `${ALARM_PREFIX}${triggerId}`;
}
/**
* 从 alarm 名称解析 triggerId
*/
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
if (!name.startsWith(ALARM_PREFIX)) return null;
const id = name.slice(ALARM_PREFIX.length);
return id ? (id as TriggerId) : null;
}
// ==================== Handler Implementation ====================
/**
* 创建 once 触发器处理器工厂
*/
export function createOnceTriggerHandlerFactory(
deps?: OnceTriggerHandlerDeps,
): TriggerHandlerFactory<'once'> {
return (fireCallback) => createOnceTriggerHandler(fireCallback, deps);
}
/**
* 创建 once 触发器处理器
*/
export function createOnceTriggerHandler(
fireCallback: TriggerFireCallback,
deps?: OnceTriggerHandlerDeps,
): TriggerHandler<'once'> {
const logger = deps?.logger ?? console;
// 延迟创建 store,避免在测试环境中出问题
let triggersStore: ReturnType<typeof createTriggersStore> | null = null;
const getTriggersStore = () => {
if (!triggersStore) {
triggersStore = createTriggersStore();
}
return triggersStore;
};
const disableTrigger =
deps?.disableTrigger ??
(async (triggerId: TriggerId) => {
const store = getTriggersStore();
const existing = await store.get(triggerId);
if (!existing) return;
if (!existing.enabled) return;
await store.save({ ...existing, enabled: false });
});
const installed = new Map<TriggerId, InstalledOnceTrigger>();
const versions = new Map<TriggerId, number>();
let listening = false;
/**
* 递增版本号以使挂起的操作失效
*/
function bumpVersion(triggerId: TriggerId): number {
const next = (versions.get(triggerId) ?? 0) + 1;
versions.set(triggerId, next);
return next;
}
/**
* 清除指定 alarm
*/
async function clearAlarmByName(name: string): Promise<void> {
if (!chrome.alarms?.clear) return;
try {
await Promise.resolve(chrome.alarms.clear(name));
} catch (e) {
logger.debug('[OnceTriggerHandler] alarms.clear failed:', e);
}
}
/**
* 清除所有 once alarms
*/
async function clearAllOnceAlarms(): Promise<void> {
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
try {
const alarms = await Promise.resolve(chrome.alarms.getAll());
const list = Array.isArray(alarms) ? alarms : [];
await Promise.all(
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
);
} catch (e) {
logger.debug('[OnceTriggerHandler] alarms.getAll failed:', e);
}
}
/**
* 调度 alarm
*/
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
if (!chrome.alarms?.create) {
logger.warn('[OnceTriggerHandler] chrome.alarms.create is unavailable');
return;
}
const entry = installed.get(triggerId);
if (!entry || entry.version !== expectedVersion) return;
const name = alarmNameForTrigger(triggerId);
try {
await Promise.resolve(chrome.alarms.create(name, { when: entry.whenMs }));
} catch (e) {
logger.error(`[OnceTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
}
}
/**
* 内部卸载逻辑(不触发外部 uninstall)
*/
async function uninstallInternal(triggerId: TriggerId): Promise<void> {
bumpVersion(triggerId);
installed.delete(triggerId);
await clearAlarmByName(alarmNameForTrigger(triggerId));
if (installed.size === 0) {
stopListening();
}
}
/**
* Alarm 事件处理
*/
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
if (!triggerId) return;
const entry = installed.get(triggerId);
if (!entry) return;
const expectedVersion = entry.version;
void (async () => {
try {
await fireCallback.onFire(triggerId, {
sourceTabId: undefined,
sourceUrl: undefined,
});
} catch (e) {
logger.error(`[OnceTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
} finally {
// 检查版本是否仍然有效
if (installed.get(triggerId)?.version === expectedVersion) {
// 禁用触发器
try {
await disableTrigger(triggerId);
} catch (e) {
logger.error(
`[OnceTriggerHandler] Failed to disable trigger "${triggerId}" after fire:`,
e,
);
}
// 卸载触发器
try {
await uninstallInternal(triggerId);
} catch (e) {
logger.error(
`[OnceTriggerHandler] Failed to uninstall trigger "${triggerId}" after fire:`,
e,
);
}
}
}
})();
};
/**
* 确保正在监听 alarm 事件
*/
function ensureListening(): void {
if (listening) return;
if (!chrome.alarms?.onAlarm?.addListener) {
logger.warn('[OnceTriggerHandler] chrome.alarms.onAlarm is unavailable');
return;
}
chrome.alarms.onAlarm.addListener(onAlarm);
listening = true;
}
/**
* 停止监听 alarm 事件
*/
function stopListening(): void {
if (!listening) return;
try {
chrome.alarms.onAlarm.removeListener(onAlarm);
} catch (e) {
logger.debug('[OnceTriggerHandler] removeListener failed:', e);
} finally {
listening = false;
}
}
return {
kind: 'once',
async install(trigger: OnceTriggerSpec): Promise<void> {
const whenMs = normalizeWhenMs(trigger.whenMs);
const version = bumpVersion(trigger.id);
installed.set(trigger.id, {
spec: { ...trigger, whenMs },
whenMs,
version,
});
ensureListening();
await schedule(trigger.id, version);
},
async uninstall(triggerId: string): Promise<void> {
await uninstallInternal(triggerId as TriggerId);
},
async uninstallAll(): Promise<void> {
for (const id of installed.keys()) {
bumpVersion(id);
}
installed.clear();
await clearAllOnceAlarms();
stopListening();
},
getInstalledIds(): string[] {
return Array.from(installed.keys());
},
};
}
@@ -145,11 +145,13 @@ export function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult<FlowV3> {
}
// 5. 计算 entryNodeId
const entryNodeId = findEntryNodeId(nodes, edges);
if (!entryNodeId) {
const entryResult = findEntryNodeId(nodes, edges);
warnings.push(...entryResult.warnings);
if (!entryResult.nodeId) {
errors.push('Could not determine entry node. No valid root node found.');
return { success: false, errors, warnings };
}
const entryNodeId = entryResult.nodeId;
// 6. 转换变量
const variables = convertVariablesV2ToV3(v2Flow.variables || []);
@@ -234,41 +236,118 @@ function convertEdgeV2ToV3(v2Edge: V2Edge): EdgeV3 | null {
return edge;
}
/** entryNodeId 计算结果 */
interface EntryNodeResult {
nodeId: NodeId | null;
warnings: string[];
}
/**
* 找到入口节点 ID
*
* 规则:
* 1. 排除 trigger 类型节点(这些是 UI 节点)
* 2. 找到入度为 0 的节点(没有边指向它
* 3. 如果有多个,选择第一个(按 ID 排序)
* 1. 排除 trigger 类型节点(这些是 UI 节点,不参与执行
* 2. 只统计「可执行节点 -> 可执行节点」的边来计算入度(忽略 trigger 指出的边
* 3. 找到入度为 0 的节点作为候选
* 4. 如果有多个候选,使用稳定选择规则:
* - 优先选择 UI 坐标最靠左上的节点(按 x 升序,x 相同按 y 升序)
* - 如果无 UI 坐标,按 ID 字典序取第一个
*/
function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): NodeId | null {
// 排除 trigger 节点
function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): EntryNodeResult {
const warnings: string[] = [];
// 1. 排除 trigger 节点,获取可执行节点
const executableNodes = nodes.filter((n) => n.kind !== 'trigger');
if (executableNodes.length === 0) {
return null;
warnings.push('No executable nodes found; cannot determine entry node');
return { nodeId: null, warnings };
}
// 计算每个节点的入度
const inDegree = new Map<string, number>();
const executableNodeIds = new Set<NodeId>(executableNodes.map((n) => n.id));
// 2. 计算入度(只统计可执行节点之间的边)
const inDegree = new Map<NodeId, number>();
for (const node of executableNodes) {
inDegree.set(node.id, 0);
}
for (const edge of edges) {
if (inDegree.has(edge.to)) {
inDegree.set(edge.to, (inDegree.get(edge.to) || 0) + 1);
// 忽略从非可执行节点(如 trigger)指出的边
if (!executableNodeIds.has(edge.from)) {
continue;
}
// 忽略指向非可执行节点的边
if (!executableNodeIds.has(edge.to)) {
continue;
}
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
}
// 找入度为 0 的节点
// 3. 找入度为 0 的节点
const rootNodes = executableNodes.filter((n) => inDegree.get(n.id) === 0);
if (rootNodes.length === 0) {
// 如果没有入度为 0 的节点,说明可能有环,取第一个可执行节点
return executableNodes[0].id;
// 没有入度为 0 的节点,说明图中存在环,使用稳定选择器选择 fallback
const fallbackResult = selectStableRootNode(executableNodes);
warnings.push(
`No inDegree=0 executable node found (graph may contain cycles); ` +
`falling back to "${fallbackResult.node.id}" by ${fallbackResult.rule}`,
);
return { nodeId: fallbackResult.node.id, warnings };
}
// 按 ID 排序,取第一个
rootNodes.sort((a, b) => a.id.localeCompare(b.id));
return rootNodes[0].id;
// 4. 单个根节点,直接返回
if (rootNodes.length === 1) {
return { nodeId: rootNodes[0].id, warnings };
}
// 5. 多个根节点,使用稳定选择规则
const selectedResult = selectStableRootNode(rootNodes);
const candidateIds = rootNodes
.map((n) => n.id)
.sort((a, b) => a.localeCompare(b))
.join(', ');
warnings.push(
`Multiple inDegree=0 executable nodes (${candidateIds}); ` +
`selected "${selectedResult.node.id}" by ${selectedResult.rule}`,
);
return { nodeId: selectedResult.node.id, warnings };
}
/** 稳定选择结果 */
interface StableSelectionResult {
node: NodeV3;
rule: string;
}
/**
* 从多个根节点中选择一个稳定的入口节点
* 优先按 UI 坐标(左上角优先),其次按 ID 字典序
*/
function selectStableRootNode(nodes: NodeV3[]): StableSelectionResult {
// 检查节点是否有有效的 UI 坐标
const hasValidUi = (n: NodeV3): n is NodeV3 & { ui: { x: number; y: number } } =>
!!n.ui && Number.isFinite(n.ui.x) && Number.isFinite(n.ui.y);
const nodesWithUi = nodes.filter(hasValidUi);
if (nodesWithUi.length > 0) {
// 按 UI 坐标排序:x 升序 -> y 升序 -> id 字典序(作为 tie-breaker
nodesWithUi.sort((a, b) => {
if (a.ui.x !== b.ui.x) return a.ui.x - b.ui.x;
if (a.ui.y !== b.ui.y) return a.ui.y - b.ui.y;
return a.id.localeCompare(b.id);
});
const selected = nodesWithUi[0];
return {
node: selected,
rule: `ui(x=${selected.ui.x}, y=${selected.ui.y})`,
};
}
// 无 UI 坐标,按 ID 字典序
const sortedById = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
return { node: sortedById[0], rule: 'id' };
}
/**
@@ -475,8 +554,7 @@ export function convertTriggerV2ToV3(v2Trigger: V2Trigger): ConversionResult<Tri
};
break;
case 'schedule': // 将 V2 schedule 转换为 cron 表达式
{
case 'schedule': { // 将 V2 schedule 转换为 cron 表达式
const cron = convertScheduleToCron(v2Trigger.schedule);
if (!cron) {
errors.push('Could not convert V2 schedule to cron expression');
@@ -517,8 +595,7 @@ function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {
if (!schedule) return null;
switch (schedule.type) {
case 'interval': // 将间隔转换为近似 cron(每 N 分钟)
{
case 'interval': { // 将间隔转换为近似 cron(每 N 分钟)
const intervalMinutes = Math.max(1, Math.round((schedule.intervalMs || 60000) / 60000));
if (intervalMinutes < 60) {
return `*/${intervalMinutes} * * * *`;
@@ -536,8 +613,7 @@ function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {
}
return '0 0 * * *'; // 默认每天 0:00
case 'weekly': // 每周指定天数和时间
{
case 'weekly': { // 每周指定天数和时间
const days = (schedule.days || [0]).join(',');
if (schedule.time) {
const [hour, minute] = schedule.time.split(':').map(Number);
@@ -0,0 +1,427 @@
/**
* DOM Tools Action Handlers
*
* Handles DOM manipulation actions:
* - triggerEvent: Dispatch a custom DOM Event on an element
* - setAttribute: Set or remove an attribute on an element
*
* Design notes:
* - Both handlers follow the same pattern as click.ts
* - Element location uses selectorLocator from shared code
* - CSS selector resolution supports ref fallback
*/
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok, tryResolveJson } from '../registry';
import type {
ActionExecutionResult,
ActionHandler,
ElementTarget,
JsonValue,
VariableStore,
} from '../types';
import {
interpolateBraces,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
// ================================
// Type Definitions
// ================================
interface ResolveRefResponse {
success?: boolean;
selector?: string;
error?: string;
}
interface DomScriptResult {
success: boolean;
error?: string;
}
interface ResolvedTarget {
selector: string;
frameId: number | undefined;
firstCandidateType?: string;
resolvedBy?: string;
}
// ================================
// Shared Utilities
// ================================
/**
* Check if target has valid ref or candidates
* Accepts unknown to safely handle malformed input in validate()
*/
function hasValidTarget(target: unknown): boolean {
if (typeof target !== 'object' || target === null) return false;
const t = target as { ref?: unknown; candidates?: unknown };
const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
return hasRef || hasCandidates;
}
/**
* Strip frame prefix from composite selector (e.g., "frame|>selector" -> "selector")
*/
function stripCompositePrefix(selector: string): string {
const raw = String(selector || '').trim();
if (!raw.includes('|>')) return raw;
const parts = raw
.split('|>')
.map((p) => p.trim())
.filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : raw;
}
/**
* Resolve ElementTarget to a CSS selector string
*
* Resolution order:
* 1. Try to locate element using selectorLocator
* 2. If ref found, resolve it to CSS selector via content script
* 3. Fall back to first CSS/attr candidate if no ref
*/
async function resolveTargetSelector(
tabId: number,
target: ElementTarget,
vars: VariableStore,
contextFrameId: number | undefined,
): Promise<{ ok: true; value: ResolvedTarget } | { ok: false; error: string }> {
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);
// Locate element using shared selector locator
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: contextFrameId,
preferRef: false,
});
const frameId = located?.frameId ?? contextFrameId;
const refToUse = located?.ref ?? selectorTarget.ref;
// Must have either ref or CSS/attr candidate
if (!refToUse && !firstCssOrAttr) {
return { ok: false, error: 'Could not locate target element' };
}
let selector: string | undefined;
// Try to resolve ref to CSS selector
if (refToUse) {
const resolved = await sendMessageToTab<ResolveRefResponse>(
tabId,
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },
frameId,
);
if (resolved.ok && resolved.value?.success !== false && resolved.value?.selector) {
const sel = resolved.value.selector.trim();
if (sel) selector = sel;
}
}
// Fall back to CSS/attr candidate
if (!selector && firstCssOrAttr) {
const stripped = stripCompositePrefix(firstCssOrAttr);
if (stripped) selector = stripped;
}
if (!selector) {
return { ok: false, error: 'Could not resolve a CSS selector for the target element' };
}
return {
ok: true,
value: {
selector,
frameId,
firstCandidateType,
// Only mark as 'ref' if locator actually resolved via ref
resolvedBy: located?.resolvedBy || (located?.ref ? 'ref' : undefined),
},
};
}
/**
* Log selector fallback if a different selector type was used
*/
function maybeLogFallback(
ctx: Parameters<typeof logSelectorFallback>[0],
actionId: string,
resolved: ResolvedTarget,
): void {
const { resolvedBy, firstCandidateType } = resolved;
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, actionId, String(firstCandidateType), String(resolvedBy));
}
}
// ================================
// triggerEvent Handler
// ================================
export const triggerEventHandler: ActionHandler<'triggerEvent'> = {
type: 'triggerEvent',
validate: (action) => {
if (!hasValidTarget(action.params.target)) {
return invalid('triggerEvent requires a target ref or selector candidates');
}
const event = action.params.event;
if (event === undefined || event === null) {
return invalid('Missing event parameter');
}
if (typeof event === 'string' && event.trim().length === 0) {
return invalid('event must be a non-empty string');
}
return ok();
},
describe: (action) => {
const ev = typeof action.params.event === 'string' ? action.params.event : '(dynamic)';
const display = ev.length > 30 ? ev.slice(0, 30) + '...' : ev;
return `Trigger event "${display}"`;
},
run: async (ctx, action): Promise<ActionExecutionResult<'triggerEvent'>> => {
const { tabId, vars, frameId } = ctx;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for triggerEvent action');
}
// Resolve event type
const eventResolved = resolveString(action.params.event, vars);
if (!eventResolved.ok) {
return failed('VALIDATION_ERROR', eventResolved.error);
}
const eventType = eventResolved.value.trim();
if (!eventType) {
return failed('VALIDATION_ERROR', 'Event type is empty');
}
// Event options
const bubbles = action.params.bubbles !== false;
const cancelable = action.params.cancelable === true;
// Ensure page is read for element location
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
// Resolve target selector
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
if (!targetResolved.ok) {
return failed('TARGET_NOT_FOUND', targetResolved.error);
}
const { selector, frameId: resolvedFrameId } = targetResolved.value;
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
// Execute event dispatch in page context
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (
sel: string,
type: string,
bubbles: boolean,
cancelable: boolean,
): DomScriptResult => {
try {
const el = document.querySelector(sel);
if (!el) {
// Use special error code to distinguish from script execution errors
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
}
const event = new Event(type, { bubbles, cancelable });
el.dispatchEvent(event);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [selector, eventType, bubbles, cancelable],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return failed('SCRIPT_FAILED', 'triggerEvent script returned invalid result');
}
const typed = result as DomScriptResult;
if (!typed.success) {
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
const errorMsg = typed.error || `Failed to dispatch "${eventType}"`;
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
? 'TARGET_NOT_FOUND'
: 'SCRIPT_FAILED';
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
}
} catch (e) {
return failed(
'SCRIPT_FAILED',
`Failed to trigger event "${eventType}": ${e instanceof Error ? e.message : String(e)}`,
);
}
maybeLogFallback(ctx, action.id, targetResolved.value);
return { status: 'success' };
},
};
// ================================
// setAttribute Handler
// ================================
export const setAttributeHandler: ActionHandler<'setAttribute'> = {
type: 'setAttribute',
validate: (action) => {
if (!hasValidTarget(action.params.target)) {
return invalid('setAttribute requires a target ref or selector candidates');
}
const name = action.params.name;
if (name === undefined || name === null) {
return invalid('Missing name parameter');
}
if (typeof name === 'string' && name.trim().length === 0) {
return invalid('name must be a non-empty string');
}
return ok();
},
describe: (action) => {
const name = typeof action.params.name === 'string' ? action.params.name : '(dynamic)';
const display = name.length > 30 ? name.slice(0, 30) + '...' : name;
return action.params.remove ? `Remove attribute "${display}"` : `Set attribute "${display}"`;
},
run: async (ctx, action): Promise<ActionExecutionResult<'setAttribute'>> => {
const { tabId, vars, frameId } = ctx;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for setAttribute action');
}
// Resolve attribute name
const nameResolved = resolveString(action.params.name, vars);
if (!nameResolved.ok) {
return failed('VALIDATION_ERROR', nameResolved.error);
}
const attrName = nameResolved.value.trim();
if (!attrName) {
return failed('VALIDATION_ERROR', 'Attribute name is empty');
}
const remove = action.params.remove === true;
// Resolve attribute value (only if not removing)
let attrValue: JsonValue = null;
if (!remove && action.params.value !== undefined) {
const valueResolved = tryResolveJson(action.params.value, vars);
if (!valueResolved.ok) {
return failed('VALIDATION_ERROR', valueResolved.error);
}
// Apply template interpolation for string values
attrValue =
typeof valueResolved.value === 'string'
? interpolateBraces(valueResolved.value, vars)
: valueResolved.value;
}
// Ensure page is read for element location
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
// Resolve target selector
const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);
if (!targetResolved.ok) {
return failed('TARGET_NOT_FOUND', targetResolved.error);
}
const { selector, frameId: resolvedFrameId } = targetResolved.value;
const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;
// Execute attribute modification in page context
try {
const injected = await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
world: 'MAIN',
func: (sel: string, name: string, value: JsonValue, remove: boolean): DomScriptResult => {
try {
const el = document.querySelector(sel);
if (!el) {
// Use special error code to distinguish from script execution errors
return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };
}
if (remove) {
el.removeAttribute(name);
} else {
// Convert value to string for setAttribute
const strValue =
value === null || value === undefined
? ''
: typeof value === 'string'
? value
: String(value);
el.setAttribute(name, strValue);
}
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
args: [selector, attrName, attrValue, remove],
});
const result = Array.isArray(injected) ? injected[0]?.result : undefined;
if (!result || typeof result !== 'object') {
return failed('SCRIPT_FAILED', 'setAttribute script returned invalid result');
}
const typed = result as DomScriptResult;
if (!typed.success) {
const actionDesc = remove ? 'remove' : 'set';
// Parse error code from message if present (e.g., "[TARGET_NOT_FOUND] ...")
const errorMsg = typed.error || `Failed to ${actionDesc} attribute "${attrName}"`;
const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')
? 'TARGET_NOT_FOUND'
: 'SCRIPT_FAILED';
return failed(code, errorMsg.replace(/^\[TARGET_NOT_FOUND\]\s*/, ''));
}
} catch (e) {
const actionDesc = remove ? 'remove' : 'set';
return failed(
'SCRIPT_FAILED',
`Failed to ${actionDesc} attribute "${attrName}": ${e instanceof Error ? e.message : String(e)}`,
);
}
maybeLogFallback(ctx, action.id, targetResolved.value);
return { status: 'success' };
},
};
@@ -11,6 +11,7 @@ import { assertHandler } from './assert';
import { clickHandler, dblclickHandler } from './click';
import { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
import { delayHandler } from './delay';
import { setAttributeHandler, triggerEventHandler } from './dom';
import { dragHandler } from './drag';
import { extractHandler } from './extract';
import { fillHandler } from './fill';
@@ -28,6 +29,7 @@ export { assertHandler } from './assert';
export { clickHandler, dblclickHandler } from './click';
export { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
export { delayHandler } from './delay';
export { setAttributeHandler, triggerEventHandler } from './dom';
export { dragHandler } from './drag';
export { extractHandler } from './extract';
export { fillHandler } from './fill';
@@ -52,6 +54,7 @@ export * from './common';
* - Timing: wait, delay
* - Validation: assert
* - Data: extract, script, http, screenshot
* - DOM Tools: triggerEvent, setAttribute
* - Tabs: openTab, switchTab, closeTab, handleDownload
* - Control Flow: if, foreach, while, switchFrame
*
@@ -78,6 +81,9 @@ const ALL_HANDLERS = [
scriptHandler,
httpHandler,
screenshotHandler,
// DOM Tools
triggerEventHandler,
setAttributeHandler,
// Tabs
openTabHandler,
switchTabHandler,
@@ -109,6 +115,8 @@ export function registerReplayHandlers(registry: ActionRegistry): void {
registry.register(scriptHandler, { override: true });
registry.register(httpHandler, { override: true });
registry.register(screenshotHandler, { override: true });
registry.register(triggerEventHandler, { override: true });
registry.register(setAttributeHandler, { override: true });
registry.register(openTabHandler, { override: true });
registry.register(switchTabHandler, { override: true });
registry.register(closeTabHandler, { override: true });
@@ -0,0 +1,557 @@
/**
* Element Picker Tool
*
* Implements chrome_request_element_selection - a human-in-the-loop tool that allows
* users to manually select elements on the page when AI cannot reliably locate them.
*/
import { createErrorResponse, type ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { ERROR_MESSAGES } from '@/common/constants';
import {
TOOL_NAMES,
type ElementPickerRequest,
type ElementPickerResult,
type ElementPickerResultItem,
type PickedElement,
} from 'chrome-mcp-shared';
// ============================================================
// Types
// ============================================================
interface NormalizedRequest {
id: string;
name: string;
description?: string;
}
interface ElementPickerToolParams {
requests: ElementPickerRequest[];
timeoutMs?: number;
tabId?: number;
windowId?: number;
}
interface PickerUiEvent {
type: string;
sessionId: string;
event: 'cancel' | 'confirm' | 'set_active_request' | 'clear_selection';
requestId?: string;
}
interface PickerFrameEvent {
type: string;
sessionId: string;
event: 'selected' | 'cancel';
requestId?: string;
element?: Omit<PickedElement, 'frameId'>;
}
// ============================================================
// Constants
// ============================================================
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
const MAX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const MIN_TIMEOUT_MS = 10 * 1000; // 10 seconds
// ============================================================
// Utility Functions
// ============================================================
function toTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeTimeoutMs(value: unknown): number {
if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS;
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
return Math.min(Math.max(Math.floor(n), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);
}
function normalizeRequests(requests: ElementPickerRequest[]): NormalizedRequest[] {
const out: NormalizedRequest[] = [];
const seen = new Set<string>();
for (let i = 0; i < requests.length; i++) {
const r = requests[i] || ({} as ElementPickerRequest);
const name = toTrimmedString(r.name);
if (!name) continue;
// Generate or use provided ID, ensuring uniqueness
const baseId = toTrimmedString(r.id) || `req_${i + 1}`;
let id = baseId;
let suffix = 2;
while (seen.has(id)) {
id = `${baseId}_${suffix++}`;
}
seen.add(id);
const description = toTrimmedString(r.description);
out.push({ id, name, description: description || undefined });
}
return out;
}
function buildResultItems(
requests: NormalizedRequest[],
pickedById: Map<string, PickedElement>,
): ElementPickerResultItem[] {
return requests.map((r) => ({
id: r.id,
name: r.name,
element: pickedById.get(r.id) || null,
}));
}
function listMissingRequestIds(
requests: NormalizedRequest[],
pickedById: Map<string, PickedElement>,
): string[] {
const missing: string[] = [];
for (const r of requests) {
if (!pickedById.has(r.id)) missing.push(r.id);
}
return missing;
}
// ============================================================
// Element Picker Tool
// ============================================================
class ElementPickerTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION;
/**
* Inject picker scripts into all frames of the tab.
*/
private async injectPickerScripts(tabId: number): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/element-picker.js'],
world: 'ISOLATED',
injectImmediately: false,
} as any);
}
/**
* Call the picker API in all frames via scripting.executeScript.
*/
private async callPickerApi(
tabId: number,
method: 'startSession' | 'stopSession' | 'setActiveRequest',
payload: Record<string, unknown>,
): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'ISOLATED',
injectImmediately: false,
func: (methodName: string, data: Record<string, unknown>) => {
try {
const api = (
globalThis as unknown as {
__mcpElementPicker?: Record<string, (data: Record<string, unknown>) => void>;
}
).__mcpElementPicker;
const fn = api && api[methodName];
if (typeof fn === 'function') {
fn(data);
}
} catch {
// Best-effort
}
},
args: [method, payload],
} as any);
}
async execute(args: ElementPickerToolParams): Promise<ToolResult> {
// Validate requests
const rawRequests = Array.isArray(args?.requests) ? args.requests : [];
if (rawRequests.length === 0) {
return createErrorResponse(`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] is required`);
}
const requests = normalizeRequests(rawRequests);
if (requests.length === 0) {
return createErrorResponse(
`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] must contain at least one non-empty name`,
);
}
const timeoutMs = normalizeTimeoutMs(args?.timeoutMs);
const sessionId = `ep_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const deadlineTs = Date.now() + timeoutMs;
// Resolve tab
let tab: chrome.tabs.Tab;
try {
const explicit = await this.tryGetTab(args?.tabId);
tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId));
} catch (error) {
return createErrorResponse(
`${ERROR_MESSAGES.TAB_NOT_FOUND}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!tab.id) {
return createErrorResponse(`${ERROR_MESSAGES.TAB_NOT_FOUND}: Active tab has no ID`);
}
const tabId = tab.id;
// Focus the tab/window for user interaction
try {
await this.ensureFocus(tab, { activate: true, focusWindow: true });
} catch {
// Best-effort: some environments disallow focusing
}
// State tracking
const pickedById = new Map<string, PickedElement>();
let activeRequestId: string | null = requests[0]?.id || null;
let uiErrorMessage: string | null = null;
let uiAvailable = true;
let finished = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let resolveResult: ((result: ElementPickerResult) => void) | null = null;
// Send UI update to content script
const sendUiUpdate = async (): Promise<void> => {
if (!uiAvailable) return;
try {
const selections: Record<string, PickedElement | null> = {};
for (const r of requests) {
selections[r.id] = pickedById.get(r.id) || null;
}
await this.sendMessageToTab(
tabId,
{
action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE,
sessionId,
activeRequestId,
selections,
deadlineTs,
errorMessage: uiErrorMessage,
},
0, // Top frame only for UI
);
} catch {
uiAvailable = false;
}
};
// Set the active request and notify all frames + UI
const setActiveRequest = async (requestId: string | null): Promise<void> => {
activeRequestId = requestId;
await this.callPickerApi(tabId, 'setActiveRequest', {
sessionId,
activeRequestId: requestId,
});
await sendUiUpdate();
};
// Finish the tool execution
const finish = async (final: {
success: boolean;
cancelled?: boolean;
timedOut?: boolean;
}): Promise<void> => {
if (finished) return;
finished = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
chrome.runtime.onMessage.removeListener(onRuntimeMessage);
// Cleanup: stop picker in all frames and hide UI
await Promise.allSettled([
this.callPickerApi(tabId, 'stopSession', { sessionId }),
uiAvailable
? this.sendMessageToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },
0,
)
: Promise.resolve(),
]);
const missing = listMissingRequestIds(requests, pickedById);
const result: ElementPickerResult = {
success: final.success,
sessionId,
timeoutMs,
cancelled: final.cancelled,
timedOut: final.timedOut,
missingRequestIds: missing.length > 0 ? missing : undefined,
results: buildResultItems(requests, pickedById),
};
resolveResult?.(result);
};
// Handle messages from content scripts
const onRuntimeMessage = (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void,
): boolean | void => {
const senderTabId = sender?.tab?.id;
if (senderTabId !== tabId) return;
const msg = message as Partial<PickerUiEvent & PickerFrameEvent> | undefined;
if (!msg || msg.sessionId !== sessionId) return;
// Handle frame events (element selection)
if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_FRAME_EVENT) {
if (msg.event === 'cancel') {
void finish({ success: false, cancelled: true });
sendResponse?.({ success: true });
return true;
}
if (msg.event === 'selected') {
const requestId = toTrimmedString(msg.requestId);
const frameId = typeof sender.frameId === 'number' ? sender.frameId : 0;
// Validate request ID
const reqExists = requestId && requests.some((r) => r.id === requestId);
if (!reqExists) {
sendResponse?.({ success: false, error: 'Unknown requestId' });
return true;
}
// Validate element data
const raw = (msg.element || {}) as Partial<Omit<PickedElement, 'frameId'>>;
const ref = toTrimmedString(raw.ref);
if (!ref) {
sendResponse?.({ success: false, error: 'Missing element.ref' });
return true;
}
// Build picked element with frameId
const selector = toTrimmedString(raw.selector);
const rect = raw.rect as PickedElement['rect'] | undefined;
const center = raw.center as PickedElement['center'] | undefined;
const picked: PickedElement = {
ref,
selector,
selectorType: 'css',
rect: rect && typeof rect === 'object' ? rect : { x: 0, y: 0, width: 0, height: 0 },
center: center && typeof center === 'object' ? center : { x: 0, y: 0 },
text: typeof raw.text === 'string' ? raw.text : undefined,
tagName: typeof raw.tagName === 'string' ? raw.tagName : undefined,
frameId,
};
pickedById.set(requestId, picked);
uiErrorMessage = null;
// Auto-advance to next missing request
const missing = listMissingRequestIds(requests, pickedById);
const next = missing.length > 0 ? missing[0] : null;
void (async () => {
try {
if (next) {
await setActiveRequest(next);
} else {
// All selected: update UI (user still needs to confirm)
await sendUiUpdate();
// If UI is unavailable, auto-confirm
if (!uiAvailable) {
await finish({ success: true });
}
}
} catch {
// Best-effort
}
})();
sendResponse?.({ success: true });
return true;
}
}
// Handle UI events (cancel, confirm, etc.)
if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT) {
if (msg.event === 'cancel') {
void finish({ success: false, cancelled: true });
sendResponse?.({ success: true });
return true;
}
if (msg.event === 'confirm') {
const missing = listMissingRequestIds(requests, pickedById);
if (missing.length > 0) {
uiErrorMessage = `Please select all elements: missing ${missing.join(', ')}`;
void sendUiUpdate();
sendResponse?.({ success: false, error: 'missing_selections', missing });
return true;
}
void finish({ success: true });
sendResponse?.({ success: true });
return true;
}
if (msg.event === 'set_active_request') {
const requestId = toTrimmedString(msg.requestId);
if (!requestId || !requests.some((r) => r.id === requestId)) {
sendResponse?.({ success: false, error: 'Unknown requestId' });
return true;
}
void setActiveRequest(requestId);
sendResponse?.({ success: true });
return true;
}
if (msg.event === 'clear_selection') {
const requestId = toTrimmedString(msg.requestId);
if (!requestId || !requests.some((r) => r.id === requestId)) {
sendResponse?.({ success: false, error: 'Unknown requestId' });
return true;
}
pickedById.delete(requestId);
uiErrorMessage = null;
void setActiveRequest(requestId);
sendResponse?.({ success: true });
return true;
}
}
return;
};
try {
// Step 1: Ensure UI content script is ready (ping + inject fallback)
const ensureUiReady = async (): Promise<boolean> => {
// Try to ping UI content script with retries
const pingWithTimeout = async (timeoutMs = 500): Promise<boolean> => {
try {
const resp = await Promise.race([
this.sendMessageToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING },
0,
),
new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('Ping timeout')), timeoutMs),
),
]);
return resp?.success === true;
} catch {
return false;
}
};
// First ping attempt (content script may already be loaded)
if (await pingWithTimeout()) return true;
// Try to inject UI content script as fallback
// Try multiple possible paths (production vs dev builds)
const possiblePaths = ['content-scripts/element-picker.js', 'element-picker.js'];
for (const path of possiblePaths) {
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds: [0] },
files: [path],
injectImmediately: true,
} as any);
// Wait a bit for script to initialize
await new Promise((r) => setTimeout(r, 150));
// Check if injection worked
if (await pingWithTimeout(300)) return true;
} catch (e) {
// Try next path
console.debug(`[ElementPicker] Path ${path} failed:`, e);
}
}
// Final attempt with longer timeout (in case of slow page)
return pingWithTimeout(1000);
};
const uiReady = await ensureUiReady();
if (!uiReady) {
console.error('[ElementPicker] UI not available after all attempts');
return createErrorResponse(
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Element Picker UI is not available. This may happen if: (1) The page blocks content scripts, (2) You're using dev mode - try restarting the dev server or use production build, (3) The page needs to be refreshed.`,
);
}
// Step 2: Show UI in top frame (must receive success:true)
try {
const showResp = await this.sendMessageToTab(
tabId,
{
action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW,
sessionId,
requests,
activeRequestId,
deadlineTs,
},
0,
);
if (showResp?.success !== true) {
throw new Error('UI did not acknowledge show message');
}
} catch (e) {
console.error('[ElementPicker] UI show failed:', e);
return createErrorResponse(
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to show Element Picker UI. Please refresh the page and try again.`,
);
}
// Step 3: Inject picker scripts and start selection engine in all frames
await this.injectPickerScripts(tabId);
await this.callPickerApi(tabId, 'startSession', { sessionId, activeRequestId });
// Register message listener
chrome.runtime.onMessage.addListener(onRuntimeMessage);
// Create result promise
const resultPromise = new Promise<ElementPickerResult>((resolve) => {
resolveResult = resolve;
});
// Set timeout
timer = setTimeout(() => {
void finish({ success: false, timedOut: true });
}, timeoutMs);
// Initial UI update
void sendUiUpdate();
// Wait for result
const result = await resultPromise;
return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };
} catch (error) {
console.error('Error in element picker tool:', error);
// Cleanup on error
try {
await Promise.allSettled([
this.callPickerApi(tabId, 'stopSession', { sessionId }),
this.sendMessageToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },
0,
),
]);
} catch {
// Best-effort cleanup
}
return createErrorResponse(
`${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export const elementPickerTool = new ElementPickerTool();
@@ -4,6 +4,7 @@ export { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-s
export { screenshotTool } from './screenshot';
export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';
export { clickTool, fillTool } from './interaction';
export { elementPickerTool } from './element-picker';
export { networkRequestTool } from './network-request';
export { networkCaptureTool } from './network-capture';
// Legacy exports (for internal use by networkCaptureTool)
@@ -2,7 +2,6 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
import {
canvasToDataURL,
createImageBitmapFromUrl,
@@ -58,6 +57,51 @@ interface ScreenshotToolParams {
maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages)
}
/** Page details returned by screenshot-helper content script */
interface ScreenshotPageDetails {
totalWidth: number;
totalHeight: number;
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: number;
currentScrollX: number;
currentScrollY: number;
}
const PAGE_DETAILS_REQUIRED_FIELDS: Array<keyof ScreenshotPageDetails> = [
'totalWidth',
'totalHeight',
'viewportWidth',
'viewportHeight',
'devicePixelRatio',
'currentScrollX',
'currentScrollY',
];
/**
* Validates and asserts that the response from content script contains valid page details
*/
function assertValidPageDetails(details: unknown): ScreenshotPageDetails {
if (!details || typeof details !== 'object') {
throw new Error(
'Screenshot helper did not respond. The content script may not be injected or cannot run on this page.',
);
}
const candidate = details as Partial<ScreenshotPageDetails>;
const invalidFields = PAGE_DETAILS_REQUIRED_FIELDS.filter(
(field) => typeof candidate[field] !== 'number' || !Number.isFinite(candidate[field]),
);
if (invalidFields.length > 0) {
throw new Error(
`Screenshot helper returned invalid page details (missing/invalid: ${invalidFields.join(', ')}).`,
);
}
return candidate as ScreenshotPageDetails;
}
/**
* Tool for capturing screenshots of web pages
*/
@@ -98,16 +142,18 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
let finalImageWidthCss: number | undefined;
let finalImageHeightCss: number | undefined;
const results: any = { base64: null, fileSaved: false };
let originalScroll = { x: 0, y: 0 };
let originalScroll: { x: number; y: number } | null = null;
let didPreparePage = false;
let pageDetails: ScreenshotPageDetails | undefined;
try {
const background = args.background === true;
// If we need content-script assisted capture (element/fullPage), we may need the tab active in its window.
// For simple viewport-only capture without selector/fullPage and background=true, prefer CDP capture.
const needInjection = fullPage || !!selector;
if (!needInjection && background) {
// CDP path: background=true with simple viewport capture (no fullPage, no selector)
const canUseCdpCapture = background && !fullPage && !selector;
// === Path 1: CDP viewport capture (no content script needed) ===
if (canUseCdpCapture) {
try {
// CDP capture of current viewport without focusing/activating
const tabId = tab.id!;
const { cdpSessionManager } = await import('@/utils/cdp-session-manager');
await cdpSessionManager.withSession(tabId, 'screenshot', async () => {
@@ -126,73 +172,84 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
const shot: any = await cdpSessionManager.sendCommand(tabId, 'Page.captureScreenshot', {
format: 'png',
});
finalImageDataUrl = `data:image/png;base64,${shot?.data || ''}`;
const base64Data = typeof shot?.data === 'string' ? shot.data : '';
if (!base64Data) {
throw new Error('CDP Page.captureScreenshot returned empty data');
}
finalImageDataUrl = `data:image/png;base64,${base64Data}`;
finalImageWidthCss = Math.round(viewport.clientWidth || 800);
finalImageHeightCss = Math.round(viewport.clientHeight || 600);
});
} catch (e) {
console.warn('CDP viewport capture failed, falling back to captureVisibleTab path:', e);
console.warn('CDP viewport capture failed, falling back to helper path:', e);
}
}
if (!finalImageDataUrl && needInjection) {
// === Path 2: Helper-assisted capture (requires content script) ===
if (!finalImageDataUrl) {
// Always inject helper when we need pageDetails
await this.injectContentScript(tab.id!, ['inject-scripts/screenshot-helper.js']);
}
// Wait for script initialization
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
// 1. Prepare page (hide scrollbars, potentially fixed elements)
await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,
options: { fullPage },
});
await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));
// Get initial page details, including original scroll position
const pageDetails = await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,
});
originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };
if (fullPage) {
this.logInfo('Capturing full page...');
const dataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
finalImageDataUrl = dataUrl;
// For full page, compute final CSS size from provided width/height fallback to pageDetails
if (args.width && args.height) {
finalImageWidthCss = args.width;
finalImageHeightCss = args.height;
} else if (args.width && !args.height) {
finalImageWidthCss = args.width;
// height will be scaled by aspect ratio; approximate with page ratio
const ratio = pageDetails.totalHeight / pageDetails.totalWidth;
finalImageHeightCss = Math.round(args.width * ratio);
} else if (!args.width && args.height) {
finalImageHeightCss = args.height;
const ratio = pageDetails.totalWidth / pageDetails.totalHeight;
finalImageWidthCss = Math.round(args.height * ratio);
} else {
finalImageWidthCss = pageDetails.totalWidth;
finalImageHeightCss = pageDetails.totalHeight;
// Prepare page (hide scrollbars, handle fixed elements)
const prepareResp = await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,
options: { fullPage },
});
if (!prepareResp || prepareResp.success !== true) {
throw new Error(
'Screenshot helper did not acknowledge page preparation. The content script may not be injected or cannot run on this page.',
);
}
} else if (selector) {
this.logInfo(`Capturing element: ${selector}`);
const cropped = await this._captureElement(tab.id!, args, pageDetails.devicePixelRatio);
finalImageDataUrl = cropped;
// For element capture, if target width/height provided, respect them; otherwise use element rect size
if (args.width && args.height) {
finalImageWidthCss = args.width;
finalImageHeightCss = args.height;
didPreparePage = true;
// Get page details with validation
const rawPageDetails = await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,
});
pageDetails = assertValidPageDetails(rawPageDetails);
originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };
if (fullPage) {
this.logInfo('Capturing full page...');
finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails);
// Compute final CSS size
if (args.width && args.height) {
finalImageWidthCss = args.width;
finalImageHeightCss = args.height;
} else if (args.width && !args.height) {
finalImageWidthCss = args.width;
const ratio = pageDetails.totalHeight / pageDetails.totalWidth;
finalImageHeightCss = Math.round(args.width * ratio);
} else if (!args.width && args.height) {
finalImageHeightCss = args.height;
const ratio = pageDetails.totalWidth / pageDetails.totalHeight;
finalImageWidthCss = Math.round(args.height * ratio);
} else {
finalImageWidthCss = pageDetails.totalWidth;
finalImageHeightCss = pageDetails.totalHeight;
}
} else if (selector) {
this.logInfo(`Capturing element: ${selector}`);
finalImageDataUrl = await this._captureElement(
tab.id!,
args,
pageDetails.devicePixelRatio,
);
if (args.width && args.height) {
finalImageWidthCss = args.width;
finalImageHeightCss = args.height;
} else {
finalImageWidthCss = pageDetails.viewportWidth;
finalImageHeightCss = pageDetails.viewportHeight;
}
} else {
// Fallback to visible element rect (already used for crop)
// We do not have easy access to rect here; use viewport as conservative default
// Visible area only
this.logInfo('Capturing visible area...');
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
finalImageWidthCss = pageDetails.viewportWidth;
finalImageHeightCss = pageDetails.viewportHeight;
}
} else if (!finalImageDataUrl) {
// Visible area only
this.logInfo('Capturing visible area...');
finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
finalImageWidthCss = pageDetails.viewportWidth;
finalImageHeightCss = pageDetails.viewportHeight;
}
if (!finalImageDataUrl) {
@@ -209,12 +266,15 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
} catch {
// ignore
}
// Use pageDetails if available, otherwise fall back to final image dimensions
const viewportWidth = pageDetails?.viewportWidth ?? finalImageWidthCss;
const viewportHeight = pageDetails?.viewportHeight ?? finalImageHeightCss;
screenshotContextManager.setContext(tab.id!, {
screenshotWidth: finalImageWidthCss,
screenshotHeight: finalImageHeightCss,
viewportWidth: pageDetails.viewportWidth,
viewportHeight: pageDetails.viewportHeight,
devicePixelRatio: pageDetails.devicePixelRatio,
viewportWidth,
viewportHeight,
devicePixelRatio: pageDetails?.devicePixelRatio,
hostname,
});
}
@@ -287,15 +347,21 @@ class ScreenshotTool extends BaseBrowserToolExecutor {
`Screenshot error: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
} finally {
// 3. Reset page
try {
await this.sendMessageToTab(tab.id!, {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,
scrollX: originalScroll.x,
scrollY: originalScroll.y,
});
} catch (err) {
console.warn('Failed to reset page, tab might have closed:', err);
// 3. Reset page only if we prepared it
if (didPreparePage) {
try {
// Only include scroll position if we successfully captured it
const resetMessage: Record<string, unknown> = {
action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,
};
if (originalScroll) {
resetMessage.scrollX = originalScroll.x;
resetMessage.scrollY = originalScroll.y;
}
await this.sendMessageToTab(tab.id!, resetMessage);
} catch (err) {
console.warn('Failed to reset page, tab might have closed:', err);
}
}
}
@@ -987,6 +987,86 @@ export function initWebEditorListeners(): void {
return true; // Async response
}
// =====================================================================
// WEB_EDITOR_OPEN_SOURCE: Open component source file in VSCode
// =====================================================================
if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE) {
(async () => {
try {
const payload = message.payload as { debugSource?: unknown } | undefined;
const debugSource = payload?.debugSource;
if (!debugSource || typeof debugSource !== 'object') {
return sendResponse({ success: false, error: 'debugSource is required' });
}
const rec = debugSource as Record<string, unknown>;
const file = typeof rec.file === 'string' ? rec.file.trim() : '';
if (!file) {
return sendResponse({ success: false, error: 'debugSource.file is required' });
}
// Read server port and selected project
const stored = await chrome.storage.local.get([
'nativeServerPort',
'agent-selected-project-id',
]);
const portRaw = stored.nativeServerPort;
const port = Number.isFinite(Number(portRaw))
? Number(portRaw)
: DEFAULT_NATIVE_SERVER_PORT;
const projectId = stored['agent-selected-project-id'];
if (!projectId || typeof projectId !== 'string') {
return sendResponse({
success: false,
error: 'No project selected. Please select a project in AgentChat first.',
});
}
// Prepare line/column
const lineRaw = Number(rec.line);
const columnRaw = Number(rec.column);
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;
const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;
// Call native-server to open file (server will validate project and path)
const openResp = await fetch(
`http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open-file`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filePath: file,
line,
column,
}),
},
);
// Try to parse JSON response for detailed error
let result: { success: boolean; error?: string };
try {
result = await openResp.json();
} catch {
const text = await openResp.text().catch(() => '');
result = {
success: false,
error: text || `HTTP ${openResp.status}`,
};
}
sendResponse(result);
} catch (err) {
sendResponse({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
})();
return true; // Async response
}
if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) {
getActiveTabId()
.then(async (tabId) => {
+362 -141
View File
@@ -70,6 +70,24 @@
</svg>
Rename
</button>
<button
class="top-btn"
:class="{ active: triggerPanelVisible }"
@click="triggerPanelVisible = !triggerPanelVisible"
title="管理触发器"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
Triggers
</button>
<span class="divider-vert" />
<button
class="top-btn"
@@ -155,6 +173,13 @@
@remove-edge="store.removeEdge"
/>
<TriggerPanel
v-if="triggerPanelVisible && store.flowLocal?.id"
class="floating-trigger"
:flow-id="store.flowLocal.id"
@close="triggerPanelVisible = false"
/>
<div class="bottom-toolbar">
<button class="toolbar-btn" @click="store.undo" title="撤销 (⌘/Ctrl+Z)">
<svg
@@ -246,8 +271,22 @@
<script lang="ts" setup>
// Dedicated full-page builder using the same inner components as popup modal
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
import type {
FlowId,
NodeId,
TriggerId,
} from '@/entrypoints/background/record-replay-v3/domain/ids';
import type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';
import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import { useRRV3Rpc } from '@/entrypoints/shared/composables';
import {
flowV2ToV3ForRpc,
flowV3ToV2ForBuilder,
isFlowV3,
extractFlowCandidates,
} from '@/entrypoints/shared/utils';
import { useBuilderStore } from '@/entrypoints/popup/components/builder/store/useBuilderStore';
import { validateFlow } from '@/entrypoints/popup/components/builder/model/validation';
@@ -255,6 +294,7 @@ import Canvas from '@/entrypoints/popup/components/builder/components/Canvas.vue
import Sidebar from '@/entrypoints/popup/components/builder/components/Sidebar.vue';
import PropertyPanel from '@/entrypoints/popup/components/builder/components/PropertyPanel.vue';
import EdgePropertyPanel from '@/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue';
import TriggerPanel from '@/entrypoints/popup/components/builder/components/TriggerPanel.vue';
const title = ref('工作流编辑器');
// theme state: persisted in localStorage and default to system preference
@@ -270,6 +310,12 @@ function toggleTheme() {
}
const store = useBuilderStore();
// V3 RPC client
const rpc = useRRV3Rpc({
autoConnect: true,
onError: (message) => pushToast(message, 'error'),
});
// toast event bus (listen to rr_toast)
type ToastItem = { id: string; message: string; level: 'info' | 'warn' | 'error' };
const toasts = ref<ToastItem[]>([]);
@@ -304,13 +350,17 @@ async function bootstrap() {
const q = getQuery();
if (q.flowId) {
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_GET_FLOW,
flowId: q.flowId,
});
if (res && res.success && res.flow) {
store.initFromFlow(res.flow);
title.value = `编辑:${res.flow.name || res.flow.id}`;
await rpc.ensureConnected();
const flowV3 = (await rpc.request('rr_v3.getFlow', {
flowId: q.flowId as FlowId,
})) as FlowV3 | null;
if (flowV3) {
const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(flowV3);
warnings.forEach((w) => pushToast(w, 'warn'));
store.initFromFlow(flowV2);
title.value = `编辑:${flowV2.name || flowV2.id}`;
if (q.focus) {
setTimeout(() => {
try {
@@ -320,26 +370,40 @@ async function bootstrap() {
} catch {}
}, 0);
}
} else {
// Flow not found - notify user and initialize empty flow
pushToast(`工作流 "${q.flowId}" 未找到,已创建新工作流`, 'warn');
initEmptyFlow();
}
} catch {}
} catch (e) {
pushToast(`加载工作流失败:${e instanceof Error ? e.message : String(e)}`, 'error');
initEmptyFlow();
}
} else if (q.new === '1') {
// Initialize an empty flow
const now = Date.now();
const empty: FlowV2 = {
id: `flow_${now}`,
name: '新建工作流',
version: 1,
steps: [],
variables: [],
meta: {
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
} as any,
} as any;
store.initFromFlow(empty);
initEmptyFlow();
}
}
/**
* 初始化一个空的工作流
*/
function initEmptyFlow() {
const now = Date.now();
const empty: FlowV2 = {
id: `flow_${now}`,
name: '新建工作流',
version: 1,
steps: [],
variables: [],
meta: {
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
} as any,
} as any;
store.initFromFlow(empty);
title.value = '新建工作流';
}
// Builder helpers mostly ported from modal component
const selectedId = computed<string | null>(() => (store.activeNodeId as any)?.value ?? null);
const selectedEdgeId = computed<string | null>(() => (store.activeEdgeId as any)?.value ?? null);
@@ -393,6 +457,9 @@ function fitAll() {
fitSeq.value++;
}
// trigger panel state
const triggerPanelVisible = ref(false);
// rename dialog
const renameVisible = ref(false);
const renameName = ref('');
@@ -408,171 +475,295 @@ function applyRename() {
renameVisible.value = false;
}
async function save() {
// Use exportFlowForSave to properly handle subflow editing:
// - Flushes current canvas state back to flowLocal (including subflow edits)
// - Returns deep copy with correct nodes/edges from flowLocal
// Note: steps are NOT generated - nodes/edges are the source of truth
const result = store.exportFlowForSave();
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW, flow: result });
/**
* 保存 Flow 到 V3 RPC
* @returns 保存成功返回 FlowV3,失败返回 null
*/
async function save(): Promise<FlowV3 | null> {
try {
// Use main flow nodes for trigger sync (not current canvas which may be subflow)
await syncTriggersAndSchedules(result.id, result.nodes || []);
} catch {}
// Use exportFlowForSave to properly handle subflow editing:
// - Flushes current canvas state back to flowLocal (including subflow edits)
// - Returns deep copy with correct nodes/edges from flowLocal
// Note: steps are NOT generated - nodes/edges are the source of truth
const flowV2 = store.exportFlowForSave();
await rpc.ensureConnected();
// Convert V2 -> V3 for RPC
const { flow: flowV3, warnings: convWarnings } = flowV2ToV3ForRpc(flowV2);
convWarnings.forEach((w) => pushToast(w, 'warn'));
// Save via RPC (cast FlowV3 to JsonObject for RPC compatibility)
const saved = (await rpc.request('rr_v3.saveFlow', {
flow: flowV3 as unknown as JsonObject,
})) as unknown as FlowV3;
// Sync timestamps back to local state
if (!store.flowLocal.meta) {
(store.flowLocal as any).meta = {};
}
(store.flowLocal as any).meta.createdAt = saved.createdAt;
(store.flowLocal as any).meta.updatedAt = saved.updatedAt;
// Sync triggers (best-effort, don't block save result)
try {
await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);
} catch {}
return saved;
} catch (e) {
pushToast(`保存失败:${e instanceof Error ? e.message : String(e)}`, 'error');
return null;
}
}
function trigId(flowId: string, nodeId: string, kind: string) {
return `trg_${flowId}_${nodeId}_${kind}`;
// ==================== Trigger Sync Helpers ====================
function trigId(flowId: string, nodeId: string, kind: string): TriggerId {
return `trg_${flowId}_${nodeId}_${kind}` as TriggerId;
}
function schId(flowId: string, nodeId: string, idx: number) {
return `sch_${flowId}_${nodeId}_${idx}`;
function schId(flowId: string, nodeId: string, idx: number): TriggerId {
return `sch_${flowId}_${nodeId}_${idx}` as TriggerId;
}
async function syncTriggersAndSchedules(flowId: string, nodes: any[]) {
const triggersNeeded: any[] = [];
const schedulesNeeded: any[] = [];
/**
* 将 V2 schedule 配置转换为 cron 表达式
* @returns cron 表达式或 null(如果无法转换)
*/
function scheduleToCron(schedule: { type?: string; when?: string }): string | null {
if (!schedule) return null;
const type = String(schedule.type || '').trim();
const when = String(schedule.when || '').trim();
if (type === 'interval') {
const minutesRaw = Number(when);
if (!Number.isFinite(minutesRaw)) return null;
const minutes = Math.max(1, Math.round(minutesRaw));
if (minutes < 60) return `*/${minutes} * * * *`;
const hours = Math.max(1, Math.round(minutes / 60));
return `0 */${hours} * * *`;
}
if (type === 'daily') {
const [hRaw, mRaw] = when.split(':');
const hourRaw = Number(hRaw);
const minuteRaw = Number(mRaw);
if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;
const hour = Math.min(23, Math.max(0, Math.floor(hourRaw)));
const minute = Math.min(59, Math.max(0, Math.floor(minuteRaw)));
return `${minute} ${hour} * * *`;
}
// V3 cron 不支持 'once' 一次性定时
return null;
}
/**
* 从 trigger 节点配置同步触发器到 V3 存储
* @description V2 schedules 会转换为 V3 cron triggers
*/
async function syncTriggersAndSchedules(flowId: string, nodes: unknown[]) {
const triggersNeeded: TriggerSpec[] = [];
const tnodes = (nodes || []).filter((n: any) => n && n.type === 'trigger');
for (const n of tnodes) {
for (const n of tnodes as any[]) {
const cfg = n.config || {};
const enabled = cfg.enabled !== false;
// URL trigger
if (cfg.modes?.url && Array.isArray(cfg.url?.rules) && cfg.url.rules.length) {
triggersNeeded.push({
id: trigId(flowId, n.id, 'url'),
type: 'url',
kind: 'url',
enabled,
flowId,
flowId: flowId as FlowId,
match: cfg.url.rules,
});
}
// Context menu trigger
if (cfg.modes?.contextMenu && cfg.contextMenu?.title) {
triggersNeeded.push({
id: trigId(flowId, n.id, 'menu'),
type: 'contextMenu',
kind: 'contextMenu',
enabled,
flowId,
flowId: flowId as FlowId,
title: cfg.contextMenu.title,
contexts: cfg.contextMenu.contexts || ['all'],
contexts: (Array.isArray(cfg.contextMenu.contexts)
? cfg.contextMenu.contexts
: ['all']
).map(String),
});
}
// Command trigger
if (cfg.modes?.command && cfg.command?.commandKey) {
triggersNeeded.push({
id: trigId(flowId, n.id, 'cmd'),
type: 'command',
kind: 'command',
enabled,
flowId,
flowId: flowId as FlowId,
commandKey: String(cfg.command.commandKey),
});
}
// DOM trigger
if (cfg.modes?.dom && cfg.dom?.selector) {
const debounceMsRaw = Number(cfg.dom.debounceMs);
triggersNeeded.push({
id: trigId(flowId, n.id, 'dom'),
type: 'dom',
kind: 'dom',
enabled,
flowId,
selector: cfg.dom.selector,
flowId: flowId as FlowId,
selector: String(cfg.dom.selector),
appear: cfg.dom.appear !== false,
once: cfg.dom.once !== false,
debounceMs: Number(cfg.dom.debounceMs ?? 800),
debounceMs: Number.isFinite(debounceMsRaw) ? debounceMsRaw : 800,
});
}
// Schedule -> Cron trigger (V3 converts schedules to cron)
if (cfg.modes?.schedule && Array.isArray(cfg.schedules)) {
cfg.schedules.forEach((s: any, i: number) => {
const id = schId(flowId, n.id, i);
schedulesNeeded.push({
id,
flowId,
type: s.type || 'interval',
when: String(s.when || ''),
enabled: s.enabled !== false,
const cron = scheduleToCron(s);
if (!cron) {
const scheduleType = String(s?.type || 'unknown');
if (scheduleType === 'once') {
pushToast(
`节点 ${n.id} 的定时 #${i + 1}: V3 暂不支持一次性定时(once),已跳过`,
'warn',
);
} else {
pushToast(
`节点 ${n.id} 的定时 #${i + 1}: 无法转换为 crontype=${scheduleType}),已跳过`,
'warn',
);
}
return;
}
triggersNeeded.push({
id: schId(flowId, n.id, i),
kind: 'cron',
enabled: enabled && s?.enabled !== false,
flowId: flowId as FlowId,
cron,
});
});
}
}
// sync triggers
// Sync triggers via V3 RPC
try {
const list =
(await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_TRIGGERS })) || {};
const existing: any[] = list.triggers || [];
const mine = existing.filter((x) => String(x.flowId) === String(flowId));
const needIds = new Set(triggersNeeded.map((t) => t.id));
// save or update
for (const t of triggersNeeded) {
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER,
trigger: t,
});
}
// delete stale
for (const t of mine) {
if (!needIds.has(t.id)) {
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER,
id: t.id,
});
await rpc.ensureConnected();
// Get existing triggers for this flow
const existing = (await rpc.request('rr_v3.listTriggers', {
flowId: flowId as FlowId,
})) as TriggerSpec[] | null;
const existingById = new Map((existing || []).map((t) => [t.id, t]));
const neededIds = new Set(triggersNeeded.map((t) => t.id));
// Create or update triggers
for (const trigger of triggersNeeded) {
// Cast TriggerSpec to JsonObject for RPC compatibility
const triggerPayload = trigger as unknown as JsonObject;
if (existingById.has(trigger.id)) {
await rpc.request('rr_v3.updateTrigger', { trigger: triggerPayload });
} else {
await rpc.request('rr_v3.createTrigger', { trigger: triggerPayload });
}
}
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS });
} catch {}
// sync schedules
try {
const list =
(await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES })) ||
{};
const existing: any[] = list.schedules || [];
const mine = existing.filter((x) => String(x.flowId) === String(flowId));
const needIds = new Set(schedulesNeeded.map((s) => s.id));
for (const s of schedulesNeeded) {
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW,
schedule: s,
});
}
for (const s of mine) {
if (!needIds.has(s.id)) {
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW,
scheduleId: s.id,
});
// Delete stale triggers (only node-managed triggers, not panel-created ones like interval/once)
// Node-managed trigger IDs have prefixes: trg_{flowId}_ or sch_{flowId}_
const nodeManagedPrefixes = [`trg_${flowId}_`, `sch_${flowId}_`];
const isNodeManaged = (triggerId: string) =>
nodeManagedPrefixes.some((prefix) => triggerId.startsWith(prefix));
for (const existing of existingById.values()) {
if (!neededIds.has(existing.id) && isNodeManaged(existing.id)) {
await rpc.request('rr_v3.deleteTrigger', { triggerId: existing.id });
}
}
} catch {}
} catch (e) {
// Best-effort sync - log for debugging but don't block user
console.warn('[Builder] Trigger sync failed:', e);
}
}
async function exportFlow() {
try {
await save();
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_EXPORT_FLOW,
flowId: store.flowLocal.id,
});
if (res && res.success) {
const blob = new Blob([res.json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
await chrome.downloads.download({
url,
filename: `${store.flowLocal.name || 'flow'}.json`,
saveAs: true,
} as any);
URL.revokeObjectURL(url);
}
} catch {}
// Save first to ensure latest changes are persisted
const saved = await save();
if (!saved) return;
// Export the V3 flow directly (no need for separate RPC call)
const blob = new Blob([JSON.stringify(saved, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
await chrome.downloads.download({
url,
filename: `${store.flowLocal.name || 'flow'}.json`,
saveAs: true,
} as chrome.downloads.DownloadOptions);
URL.revokeObjectURL(url);
} catch (e) {
pushToast(`导出失败:${e instanceof Error ? e.message : String(e)}`, 'error');
}
}
async function onImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const txt = await file.text();
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_IMPORT_FLOW,
json: txt,
});
if (res && res.success && Array.isArray(res.flows) && res.flows.length) {
store.initFromFlow(res.flows[0]);
const parsed = JSON.parse(txt);
const candidates = extractFlowCandidates(parsed);
if (!candidates.length) {
pushToast('导入失败:未找到工作流数据', 'error');
return;
}
} catch {
const first = candidates[0];
if (isFlowV3(first)) {
// V3 format: save via RPC, then load into builder
await rpc.ensureConnected();
const saved = (await rpc.request('rr_v3.saveFlow', {
flow: first as unknown as JsonObject,
})) as unknown as FlowV3;
const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(saved);
warnings.forEach((w) => pushToast(w, 'warn'));
store.initFromFlow(flowV2);
title.value = `编辑:${flowV2.name || flowV2.id}`;
// Sync triggers
try {
await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);
} catch {}
} else {
// V2 format: load directly, then save to convert to V3
store.initFromFlow(first as FlowV2);
// If V2 flow has steps but no nodes, trigger conversion
if (
Array.isArray((first as any)?.steps) &&
(!Array.isArray((first as any)?.nodes) || (first as any).nodes.length === 0)
) {
store.importFromSteps();
}
title.value = `编辑:${store.flowLocal.name || store.flowLocal.id}`;
await save(); // Convert and save as V3
}
} catch (e) {
pushToast(`导入失败:${e instanceof Error ? e.message : String(e)}`, 'error');
} finally {
input.value = '';
}
@@ -580,25 +771,38 @@ async function onImport(e: Event) {
async function runFromSelected() {
if (!selectedId.value || !store.flowLocal?.id) return;
try {
await save();
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
flowId: store.flowLocal.id,
options: { returnLogs: false, startNodeId: selectedId.value },
const saved = await save();
if (!saved) return;
await rpc.ensureConnected();
// Skip trigger nodes (they can't be start nodes)
const node = store.nodes.find((n) => n.id === selectedId.value) || null;
const startNodeId = node?.type === 'trigger' ? undefined : selectedId.value;
await rpc.request('rr_v3.enqueueRun', {
flowId: saved.id as FlowId,
...(startNodeId ? { startNodeId: startNodeId as NodeId } : {}),
});
} catch {}
} catch (e) {
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
}
}
async function runAll() {
if (!store.flowLocal?.id) return;
try {
await save();
await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,
flowId: store.flowLocal.id,
options: { returnLogs: false },
});
} catch {}
const saved = await save();
if (!saved) return;
await rpc.ensureConnected();
await rpc.request('rr_v3.enqueueRun', { flowId: saved.id as FlowId });
} catch (e) {
pushToast(`运行失败:${e instanceof Error ? e.message : String(e)}`, 'error');
}
}
// Hotkeys
@@ -647,15 +851,20 @@ const saveState = ref<'idle' | 'saving' | 'saved'>('idle');
const saveLabel = computed(() =>
saveState.value === 'saving' ? '保存中…' : saveState.value === 'saved' ? '已保存' : '',
);
let saveTimer: any = null;
let statusTimer: any = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let statusTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAutoSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
saveState.value = 'saving';
await new Promise((r) => setTimeout(r, 0));
save();
const saved = await save();
if (!saved) {
saveState.value = 'idle';
return;
}
saveState.value = 'saved';
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(() => (saveState.value = 'idle'), 1200);
@@ -810,6 +1019,13 @@ function focusNode(id: string) {
z-index: 10;
pointer-events: auto;
}
.floating-trigger {
position: absolute;
right: 400px; /* offset from property panel */
top: 52px;
z-index: 10;
pointer-events: auto;
}
.bottom-toolbar {
position: absolute;
left: 50%;
@@ -878,6 +1094,11 @@ function focusNode(id: string) {
opacity: 0.5;
cursor: not-allowed;
}
.top-btn.active {
background: var(--rr-accent);
color: #fff;
border-color: var(--rr-accent);
}
.top-btn.primary {
background: var(--rr-accent);
color: #fff;
@@ -0,0 +1,205 @@
/**
* Element Picker Content Script
*
* Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events
* to background while a chrome_request_element_selection session is active.
*
* This script only runs in the top frame and handles:
* - Displaying the element picker panel UI
* - Forwarding user actions (cancel, confirm, etc.) to background
* - Receiving state updates from background
*/
import {
createElementPickerController,
type ElementPickerController,
type ElementPickerUiState,
} from '@/shared/element-picker';
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
import type { PickedElement } from 'chrome-mcp-shared';
// ============================================================
// Message Types
// ============================================================
interface UiShowMessage {
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW;
sessionId: string;
requests: Array<{ id: string; name: string; description?: string }>;
activeRequestId: string | null;
deadlineTs: number;
}
interface UiUpdateMessage {
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE;
sessionId: string;
activeRequestId: string | null;
selections: Record<string, PickedElement | null>;
deadlineTs: number;
errorMessage: string | null;
}
interface UiHideMessage {
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE;
sessionId: string;
}
interface UiPingMessage {
action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING;
}
type PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage;
// ============================================================
// Content Script Definition
// ============================================================
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_idle',
main() {
// Only mount UI in the top frame
if (window.top !== window) return;
let controller: ElementPickerController | null = null;
let currentSessionId: string | null = null;
/**
* Ensure the controller is created and configured.
*/
function ensureController(): ElementPickerController {
if (controller) return controller;
controller = createElementPickerController({
onCancel: () => {
if (!currentSessionId) return;
void chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
sessionId: currentSessionId,
event: 'cancel',
});
},
onConfirm: () => {
if (!currentSessionId) return;
void chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
sessionId: currentSessionId,
event: 'confirm',
});
},
onSetActiveRequest: (requestId: string) => {
if (!currentSessionId) return;
void chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
sessionId: currentSessionId,
event: 'set_active_request',
requestId,
});
},
onClearSelection: (requestId: string) => {
if (!currentSessionId) return;
void chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
sessionId: currentSessionId,
event: 'clear_selection',
requestId,
});
},
});
return controller;
}
/**
* Handle incoming messages from background.
*/
function handleMessage(
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void,
): boolean | void {
const msg = message as PickerMessage | undefined;
if (!msg?.action) return false;
// Respond to ping (used by background to check if UI script is ready)
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) {
sendResponse({ success: true });
return true;
}
// Show the picker panel
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) {
const showMsg = msg as UiShowMessage;
currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null;
if (!currentSessionId) {
sendResponse({ success: false, error: 'Missing sessionId' });
return true;
}
const ctrl = ensureController();
const initialState: ElementPickerUiState = {
sessionId: currentSessionId,
requests: Array.isArray(showMsg.requests) ? showMsg.requests : [],
activeRequestId: showMsg.activeRequestId ?? null,
selections: {},
deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(),
errorMessage: null,
};
ctrl.show(initialState);
sendResponse({ success: true });
return true;
}
// Update the picker panel state
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) {
const updateMsg = msg as UiUpdateMessage;
if (!currentSessionId || updateMsg.sessionId !== currentSessionId) {
sendResponse({ success: false, error: 'Session mismatch' });
return true;
}
controller?.update({
sessionId: currentSessionId,
activeRequestId: updateMsg.activeRequestId ?? null,
selections: updateMsg.selections || {},
deadlineTs: updateMsg.deadlineTs,
errorMessage: updateMsg.errorMessage ?? null,
});
sendResponse({ success: true });
return true;
}
// Hide the picker panel
if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) {
const hideMsg = msg as UiHideMessage;
// Best-effort hide even if session mismatches
if (currentSessionId && hideMsg.sessionId !== currentSessionId) {
// Log but don't fail
console.warn('[ElementPicker] Session mismatch on hide, hiding anyway');
}
controller?.hide();
currentSessionId = null;
sendResponse({ success: true });
return true;
}
return false;
}
// Register message listener
chrome.runtime.onMessage.addListener(handleMessage);
// Cleanup on page unload
window.addEventListener('unload', () => {
chrome.runtime.onMessage.removeListener(handleMessage);
controller?.dispose();
controller = null;
currentSessionId = null;
});
},
});
+143 -33
View File
@@ -73,19 +73,16 @@
<h2 class="section-title">快捷工具</h2>
<div class="rr-icon-buttons">
<button
class="rr-icon-btn rr-icon-btn-record has-tooltip"
:class="{ 'rr-icon-btn-recording': rrRecording }"
class="rr-icon-btn rr-icon-btn-record rr-icon-btn-coming-soon has-tooltip"
@click="startRecording"
:disabled="rrRecording"
:data-tooltip="rrRecording ? '录制中...' : '开始录制'"
data-tooltip="录制功能开发中"
>
<RecordIcon :recording="rrRecording" />
<RecordIcon :recording="false" />
</button>
<button
class="rr-icon-btn rr-icon-btn-stop has-tooltip"
class="rr-icon-btn rr-icon-btn-stop rr-icon-btn-coming-soon has-tooltip"
@click="stopRecording"
:disabled="!rrRecording"
data-tooltip="停止并保存"
data-tooltip="录制功能开发中"
>
<StopIcon />
</button>
@@ -143,12 +140,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button class="entry-item" @click="openWorkflowSidepanel">
<button class="entry-item entry-item-coming-soon" @click="openWorkflowSidepanel">
<div class="entry-icon workflow">
<WorkflowIcon />
</div>
<div class="entry-content">
<span class="entry-title">工作流管理</span>
<span class="entry-title">
工作流管理
<span class="coming-soon-badge">Coming Soon</span>
</span>
<span class="entry-desc">录制与回放自动化流程</span>
</div>
<svg
@@ -311,6 +311,23 @@
/>
<!-- 侧边栏承担工作流管理编辑器在独立窗口中打开 -->
<!-- Coming Soon Toast -->
<Transition name="toast">
<div v-if="comingSoonToast.show" class="coming-soon-toast">
<svg
class="toast-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{{ comingSoonToast.feature }} 功能开发中敬请期待</span>
</div>
</Transition>
</div>
</template>
@@ -355,6 +372,16 @@ const { theme: agentTheme, initTheme } = useAgentTheme();
// 当前视图状态:首页 or 本地模型页
const currentView = ref<'home' | 'local-model'>('home');
// Coming Soon Toast
const comingSoonToast = ref<{ show: boolean; feature: string }>({ show: false, feature: '' });
function showComingSoonToast(feature: string) {
comingSoonToast.value = { show: true, feature };
setTimeout(() => {
comingSoonToast.value = { show: false, feature: '' };
}, 2000);
}
// Record & Replay state
const rrRecording = ref(false);
const rrFlows = ref<
@@ -405,31 +432,37 @@ function isFlowBoundToCurrent(flow: any) {
// 运行记录与覆盖项在侧边栏页面查看
const startRecording = async () => {
if (rrRecording.value) return;
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING,
meta: { name: '新录制' },
});
rrRecording.value = !!(res && res.success);
} catch (e) {
console.error('开始录制失败:', e);
rrRecording.value = false;
}
// TODO: 录制回放功能开发中,暂时拦截
showComingSoonToast('录制回放');
return;
// if (rrRecording.value) return;
// try {
// const res = await chrome.runtime.sendMessage({
// type: BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING,
// meta: { name: '新录制' },
// });
// rrRecording.value = !!(res && res.success);
// } catch (e) {
// console.error('开始录制失败:', e);
// rrRecording.value = false;
// }
};
const stopRecording = async () => {
if (!rrRecording.value) return;
try {
const res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING,
});
rrRecording.value = false;
if (res && res.success) await loadFlows();
} catch (e) {
console.error('停止录制失败:', e);
rrRecording.value = false;
}
// TODO: 录制回放功能开发中,暂时拦截
showComingSoonToast('录制回放');
return;
// if (!rrRecording.value) return;
// try {
// const res = await chrome.runtime.sendMessage({
// type: BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING,
// });
// rrRecording.value = false;
// if (res && res.success) await loadFlows();
// } catch (e) {
// console.error('停止录制失败:', e);
// rrRecording.value = false;
// }
};
const runFlow = async (flowId: string) => {
@@ -601,7 +634,9 @@ async function openSidepanelAndClose(tab: string) {
// Open sidepanel from popup for workflow management
function openWorkflowSidepanel() {
openSidepanelAndClose('workflows');
// TODO: 工作流功能开发中,暂时拦截
showComingSoonToast('工作流管理');
// openSidepanelAndClose('workflows');
}
// Open sidepanel for element marker management
@@ -2423,6 +2458,18 @@ onUnmounted(() => {
color: #059669;
}
/* Coming Soon 按钮样式 */
.rr-icon-btn-coming-soon {
opacity: 0.5;
cursor: default !important;
}
.rr-icon-btn-coming-soon:hover {
transform: none !important;
box-shadow: none !important;
opacity: 0.6;
}
/* CSS Tooltip - instant display */
.has-tooltip {
position: relative;
@@ -2566,4 +2613,67 @@ onUnmounted(() => {
color: var(--ac-text-subtle, #a8a29e);
flex-shrink: 0;
}
/* Coming Soon Badge */
.coming-soon-badge {
display: inline-flex;
align-items: center;
margin-left: 6px;
padding: 2px 6px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--ac-accent, #d97757);
background: rgba(217, 119, 87, 0.12);
border-radius: 4px;
vertical-align: middle;
}
.entry-item-coming-soon {
opacity: 0.7;
}
.entry-item-coming-soon:hover {
opacity: 0.85;
}
/* Coming Soon Toast */
.coming-soon-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--ac-text, #1a1a1a);
color: var(--ac-text-inverse, #ffffff);
font-size: 13px;
font-weight: 500;
border-radius: var(--ac-radius-card, 12px);
box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.15));
z-index: 1000;
white-space: nowrap;
}
.toast-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--ac-accent, #d97757);
}
/* Toast transition */
.toast-enter-active,
.toast-leave-active {
transition: all 0.25s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
</style>
@@ -8,26 +8,28 @@
<input class="search-input" placeholder="Insert node..." v-model="q" />
</div>
<!-- Flow -->
<div class="section-divider">
<span class="divider-label">Flow</span>
</div>
<div class="nodes-section">
<button
v-for="n in filtered.Flow"
:key="n.type"
class="node-btn"
draggable="true"
@dragstart="onDragStart(n.type, $event)"
@click="$emit('addNode', n.type)"
:title="n.label"
>
<div class="btn-icon" :class="n.iconClass">
<component :is="iconComp(n.type)" />
</div>
<span class="btn-label">{{ n.label }}</span>
</button>
</div>
<!-- Flow (only show if there are nodes in this category) -->
<template v-if="filtered.Flow.length > 0">
<div class="section-divider">
<span class="divider-label">Flow</span>
</div>
<div class="nodes-section">
<button
v-for="n in filtered.Flow"
:key="n.type"
class="node-btn"
draggable="true"
@dragstart="onDragStart(n.type, $event)"
@click="$emit('addNode', n.type)"
:title="n.label"
>
<div class="btn-icon" :class="n.iconClass">
<component :is="iconComp(n.type)" />
</div>
<span class="btn-label">{{ n.label }}</span>
</button>
</div>
</template>
<!-- Actions -->
<div class="nodes-section">
@@ -155,6 +157,7 @@ const filtered = computed(() => {
)
: items;
return {
Flow: list.filter((x) => x.category === 'Flow'),
Actions: list.filter((x) => x.category === 'Actions'),
Tools: list.filter((x) => x.category === 'Tools'),
Tabs: list.filter((x) => x.category === 'Tabs'),
@@ -0,0 +1,941 @@
/** * @fileoverview Trigger Panel Component for Builder * @description * A floating panel for
managing V3 triggers in the Builder interface. * * Features: * - Lists all triggers for the current
flow * - Enable/disable toggle for all trigger types * - Create/edit/delete for panel-managed
triggers (interval, once) * - Manual trigger support for 'manual' type triggers * * Ownership model:
* - Node-managed triggers (ID prefix: trg_/sch_): Created by trigger node sync, read-only in panel *
- Panel-managed triggers (interval, once): Full CRUD in panel */
<template>
<aside class="trigger-panel">
<div class="panel-header">
<div class="header-left">
<div class="header-title">Triggers</div>
<div class="header-sub">{{ flowId }}</div>
</div>
<div class="header-right">
<button class="btn-sm" type="button" :disabled="loading" @click="refresh"> Refresh </button>
<button class="btn-close" type="button" title="Close" @click="emit('close')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="m4 4 8 8M12 4 4 12"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<div class="panel-content">
<!-- Create Section (interval/once only) -->
<div class="form-section">
<div class="section-header">
<div class="section-title">Add Trigger</div>
<div class="section-actions">
<button class="btn-sm" type="button" @click="openCreate('interval')">+ Interval</button>
<button class="btn-sm" type="button" @click="openCreate('once')">+ Once</button>
</div>
</div>
<div class="hint">
Other types (url/cron/command/contextMenu/dom) are configured via trigger nodes.
</div>
</div>
<div class="divider"></div>
<!-- Trigger List -->
<div class="form-section">
<div class="section-header">
<div class="section-title">Current Triggers ({{ triggers.length }})</div>
</div>
<div v-if="loading" class="muted">Loading...</div>
<div v-else-if="triggers.length === 0" class="muted">No triggers configured</div>
<div v-else class="trigger-list">
<div v-for="trigger in sortedTriggers" :key="trigger.id" class="trigger-row">
<div class="trigger-main">
<div class="trigger-top">
<span class="badge" :data-kind="trigger.kind">{{ trigger.kind }}</span>
<span class="trigger-id">{{ trigger.id }}</span>
<span
v-if="ownerOf(trigger) !== 'panel'"
class="ownership"
:data-owner="ownerOf(trigger)"
>
{{ ownerLabel(ownerOf(trigger)) }}
</span>
</div>
<div class="trigger-desc">{{ describeTrigger(trigger) }}</div>
</div>
<div class="trigger-actions">
<label
class="toggle"
:class="{ readonly: ownerOf(trigger) === 'triggerNode' }"
:title="
ownerOf(trigger) === 'triggerNode' ? 'Edit via trigger node in Builder' : ''
"
>
<input
type="checkbox"
:checked="trigger.enabled"
:disabled="busyIds[trigger.id] || ownerOf(trigger) === 'triggerNode'"
@change="onToggleEnabled(trigger, ($event.target as HTMLInputElement).checked)"
/>
<span>Enabled</span>
</label>
<button
v-if="trigger.kind === 'manual'"
class="btn-sm btn-primary"
type="button"
:disabled="busyIds[trigger.id] || !trigger.enabled"
@click="fireManual(trigger)"
>
Fire
</button>
<template v-if="isPanelManaged(trigger)">
<button
class="btn-icon-sm"
type="button"
title="Edit"
:disabled="busyIds[trigger.id]"
@click="openEdit(trigger)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z" />
</svg>
</button>
<button
class="btn-icon-sm danger"
type="button"
title="Delete"
:disabled="busyIds[trigger.id]"
@click="removePanelTrigger(trigger)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"
/>
</svg>
</button>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Editor Modal -->
<div v-if="editorOpen" class="rr-modal" @click.self="closeEditor">
<div class="rr-dialog small">
<div class="rr-header">
<div class="title">{{ editorTitle }}</div>
<button class="close" type="button" @click="closeEditor">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="m4 4 8 8M12 4 4 12"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="rr-body">
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-select" v-model="editorKind" :disabled="editorMode === 'edit'">
<option value="interval">interval</option>
<option value="once">once</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" v-model="editorEnabled" />
<span>Enabled</span>
</label>
</div>
<template v-if="editorKind === 'interval'">
<div class="form-group">
<label class="form-label">Interval (minutes)</label>
<input
class="form-input"
type="number"
min="1"
step="1"
v-model.number="editorPeriodMinutes"
/>
</div>
<div class="hint">Uses chrome.alarms.periodInMinutes for repeating triggers.</div>
</template>
<template v-else>
<div class="form-group">
<label class="form-label">Trigger Time</label>
<input class="form-input" type="datetime-local" v-model="editorWhenLocal" />
</div>
<div class="hint"> Will auto-disable after firing. Time is in local timezone. </div>
</template>
</div>
<div class="rr-footer">
<button class="btn-cancel" type="button" @click="closeEditor">Cancel</button>
<button class="btn-primary" type="button" :disabled="editorSaving" @click="submitEditor">
Save
</button>
</div>
</div>
</div>
</aside>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import type { FlowId, TriggerId } from '@/entrypoints/background/record-replay-v3/domain/ids';
import type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';
import type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import { useRRV3Rpc } from '@/entrypoints/shared/composables';
import { toast } from '@/entrypoints/popup/components/builder/model/toast';
// ==================== Types ====================
type PanelEditableKind = 'interval' | 'once';
type TriggerOwner = 'panel' | 'triggerNode' | 'external';
// ==================== Props & Emits ====================
const props = defineProps<{
flowId: string;
}>();
const emit = defineEmits<{
(e: 'close'): void;
}>();
defineOptions({ name: 'TriggerPanel' });
// ==================== RPC & State ====================
const rpc = useRRV3Rpc({ autoConnect: true });
const loading = ref(false);
const triggers = ref<TriggerSpec[]>([]);
const busyIds = ref<Record<string, boolean>>({});
const sortedTriggers = computed(() => {
return [...triggers.value].sort((a, b) => {
const kindOrder = a.kind.localeCompare(b.kind);
if (kindOrder !== 0) return kindOrder;
return a.id.localeCompare(b.id);
});
});
// ==================== Editor State ====================
const editorOpen = ref(false);
const editorSaving = ref(false);
const editorMode = ref<'create' | 'edit'>('create');
const editorKind = ref<PanelEditableKind>('interval');
const editorEditingId = ref<TriggerId | null>(null);
const editorEnabled = ref(true);
const editorPeriodMinutes = ref(5);
const editorWhenLocal = ref('');
const editorTitle = computed(() => {
const mode = editorMode.value === 'create' ? 'Create' : 'Edit';
return `${mode} ${editorKind.value} Trigger`;
});
// ==================== Utilities ====================
function setBusy(triggerId: string, value: boolean): void {
busyIds.value = { ...busyIds.value, [triggerId]: value };
}
function formatLocalDateTime(ms: number): string {
const date = new Date(ms);
if (!Number.isFinite(date.getTime())) return String(ms);
return date.toLocaleString();
}
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function unixMsToDatetimeLocal(ms: number): string {
const date = new Date(ms);
if (!Number.isFinite(date.getTime())) return '';
const year = date.getFullYear();
const month = pad2(date.getMonth() + 1);
const day = pad2(date.getDate());
const hour = pad2(date.getHours());
const minute = pad2(date.getMinutes());
return `${year}-${month}-${day}T${hour}:${minute}`;
}
function datetimeLocalToUnixMs(value: string): number | null {
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) return null;
const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr] = match;
const date = new Date(
Number(yearStr),
Number(monthStr) - 1,
Number(dayStr),
Number(hourStr),
Number(minuteStr),
Number(secondStr || 0),
0,
);
const ms = date.getTime();
return Number.isFinite(ms) ? ms : null;
}
// ==================== Trigger Ownership ====================
function isPanelManaged(trigger: TriggerSpec): boolean {
return trigger.kind === 'interval' || trigger.kind === 'once';
}
function ownerOf(trigger: TriggerSpec): TriggerOwner {
const flowId = String(props.flowId || '');
const trigPrefix = `trg_${flowId}_`;
const schPrefix = `sch_${flowId}_`;
if (trigger.id.startsWith(trigPrefix) || trigger.id.startsWith(schPrefix)) {
return 'triggerNode';
}
if (isPanelManaged(trigger)) {
return 'panel';
}
return 'external';
}
function ownerLabel(owner: TriggerOwner): string {
switch (owner) {
case 'triggerNode':
return 'via trigger node';
case 'external':
return 'external';
default:
return '';
}
}
// ==================== Trigger Description ====================
function describeTrigger(trigger: TriggerSpec): string {
switch (trigger.kind) {
case 'url': {
const spec = trigger as Extract<TriggerSpec, { kind: 'url' }>;
const rules = spec.match || [];
return `URL match rules: ${rules.length}`;
}
case 'cron': {
const spec = trigger as Extract<TriggerSpec, { kind: 'cron' }>;
return spec.timezone ? `cron: ${spec.cron} (${spec.timezone})` : `cron: ${spec.cron}`;
}
case 'interval': {
const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;
return `Every ${spec.periodMinutes} minute(s)`;
}
case 'once': {
const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;
return `At ${formatLocalDateTime(Number(spec.whenMs))}`;
}
case 'command': {
const spec = trigger as Extract<TriggerSpec, { kind: 'command' }>;
return `commandKey: ${spec.commandKey}`;
}
case 'contextMenu': {
const spec = trigger as Extract<TriggerSpec, { kind: 'contextMenu' }>;
return `title: ${spec.title}`;
}
case 'dom': {
const spec = trigger as Extract<TriggerSpec, { kind: 'dom' }>;
return `selector: ${spec.selector}`;
}
case 'manual':
return 'Manual trigger (fire via button)';
default:
return '';
}
}
// ==================== Data Actions ====================
async function refresh(): Promise<void> {
const flowId = String(props.flowId || '').trim();
if (!flowId) return;
loading.value = true;
try {
await rpc.ensureConnected();
const result = (await rpc.request('rr_v3.listTriggers', {
flowId: flowId as FlowId,
})) as TriggerSpec[] | null;
triggers.value = Array.isArray(result) ? result : [];
} catch (e) {
toast(e instanceof Error ? e.message : String(e), 'error');
} finally {
loading.value = false;
}
}
async function onToggleEnabled(trigger: TriggerSpec, enabled: boolean): Promise<void> {
if (busyIds.value[trigger.id]) return;
setBusy(trigger.id, true);
try {
// Node-managed triggers have toggle disabled, so this only applies to panel-managed
await rpc.ensureConnected();
const method = enabled ? 'rr_v3.enableTrigger' : 'rr_v3.disableTrigger';
await rpc.request(method, { triggerId: trigger.id as TriggerId });
await refresh();
} catch (e) {
toast(e instanceof Error ? e.message : String(e), 'error');
} finally {
setBusy(trigger.id, false);
}
}
async function fireManual(trigger: TriggerSpec): Promise<void> {
if (trigger.kind !== 'manual') return;
if (busyIds.value[trigger.id]) return;
setBusy(trigger.id, true);
try {
await rpc.ensureConnected();
const result = (await rpc.request('rr_v3.fireTrigger', {
triggerId: trigger.id as TriggerId,
})) as { runId?: string } | null;
toast(`Triggered: ${result?.runId ?? 'run enqueued'}`, 'info');
} catch (e) {
toast(e instanceof Error ? e.message : String(e), 'error');
} finally {
setBusy(trigger.id, false);
}
}
// ==================== Editor Actions ====================
function openCreate(kind: PanelEditableKind): void {
editorMode.value = 'create';
editorKind.value = kind;
editorEditingId.value = null;
editorEnabled.value = true;
editorPeriodMinutes.value = 5;
editorWhenLocal.value = unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000);
editorOpen.value = true;
}
function openEdit(trigger: TriggerSpec): void {
if (!isPanelManaged(trigger)) return;
editorMode.value = 'edit';
editorKind.value = trigger.kind as PanelEditableKind;
editorEditingId.value = trigger.id as TriggerId;
editorEnabled.value = !!trigger.enabled;
if (trigger.kind === 'interval') {
const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;
editorPeriodMinutes.value = Number(spec.periodMinutes) || 1;
} else {
const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;
editorWhenLocal.value = unixMsToDatetimeLocal(Number(spec.whenMs));
}
editorOpen.value = true;
}
function closeEditor(): void {
if (editorSaving.value) return;
editorOpen.value = false;
}
async function submitEditor(): Promise<void> {
if (editorSaving.value) return;
const flowId = String(props.flowId || '').trim();
if (!flowId) {
toast('Flow ID is empty', 'error');
return;
}
editorSaving.value = true;
try {
await rpc.ensureConnected();
let payload: Record<string, unknown>;
if (editorKind.value === 'interval') {
const periodMinutes = Math.max(1, Math.floor(Number(editorPeriodMinutes.value || 1)));
payload = {
kind: 'interval',
enabled: !!editorEnabled.value,
flowId: flowId as FlowId,
periodMinutes,
};
if (editorEditingId.value) {
payload.id = editorEditingId.value;
}
} else {
const whenMs = datetimeLocalToUnixMs(editorWhenLocal.value);
if (whenMs === null) {
toast('Invalid trigger time format', 'error');
return;
}
if (whenMs < Date.now()) {
toast('Trigger time is in the past. It may fire immediately.', 'warn');
}
payload = {
kind: 'once',
enabled: !!editorEnabled.value,
flowId: flowId as FlowId,
whenMs,
};
if (editorEditingId.value) {
payload.id = editorEditingId.value;
}
}
if (editorMode.value === 'create') {
await rpc.request('rr_v3.createTrigger', { trigger: payload as unknown as JsonObject });
} else {
await rpc.request('rr_v3.updateTrigger', { trigger: payload as unknown as JsonObject });
}
editorOpen.value = false;
await refresh();
} catch (e) {
toast(e instanceof Error ? e.message : String(e), 'error');
} finally {
editorSaving.value = false;
}
}
async function removePanelTrigger(trigger: TriggerSpec): Promise<void> {
if (!isPanelManaged(trigger)) return;
const confirmed = confirm(`Delete trigger?\n\n${trigger.id}`);
if (!confirmed) return;
if (busyIds.value[trigger.id]) return;
setBusy(trigger.id, true);
try {
await rpc.ensureConnected();
await rpc.request('rr_v3.deleteTrigger', { triggerId: trigger.id });
await refresh();
} catch (e) {
toast(e instanceof Error ? e.message : String(e), 'error');
} finally {
setBusy(trigger.id, false);
}
}
// ==================== Lifecycle ====================
watch(
() => props.flowId,
() => {
void refresh();
},
{ immediate: true },
);
</script>
<style scoped>
.trigger-panel {
background: var(--rr-card);
border: 1px solid var(--rr-border);
border-radius: 16px;
margin: 16px;
padding: 0;
width: 420px;
max-height: calc(100vh - 72px);
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
scrollbar-width: none;
}
.trigger-panel::-webkit-scrollbar {
width: 0;
height: 0;
}
/* Header */
.panel-header {
padding: 12px 12px 12px 20px;
border-bottom: 1px solid var(--rr-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--rr-text);
margin-bottom: 4px;
}
.header-sub {
font-size: 11px;
color: var(--rr-text-weak);
font-family: 'Monaco', monospace;
opacity: 0.7;
word-break: break-all;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--rr-border);
background: var(--rr-card);
color: var(--rr-text-secondary);
border-radius: 6px;
cursor: pointer;
}
.btn-close:hover {
background: var(--rr-hover);
border-color: var(--rr-text-weak);
color: var(--rr-text);
}
/* Content */
.panel-content {
display: flex;
flex-direction: column;
}
.form-section {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--rr-text);
}
.section-actions {
display: flex;
gap: 8px;
}
.hint {
font-size: 12px;
color: var(--rr-text-weak);
line-height: 1.5;
}
.muted {
font-size: 12px;
color: var(--rr-text-weak);
}
.divider {
height: 1px;
background: var(--rr-border);
}
/* Trigger List */
.trigger-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.trigger-row {
border: 1px solid var(--rr-border);
border-radius: 10px;
padding: 10px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: start;
}
.trigger-top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
color: var(--rr-text);
}
.trigger-id {
font-size: 11px;
color: var(--rr-text-weak);
font-family: 'Monaco', monospace;
opacity: 0.85;
word-break: break-all;
}
.ownership {
font-size: 11px;
color: var(--rr-text-weak);
padding: 2px 6px;
border: 1px dashed var(--rr-border);
border-radius: 999px;
}
.trigger-desc {
margin-top: 6px;
font-size: 12px;
color: var(--rr-text-secondary);
line-height: 1.5;
word-break: break-word;
}
.trigger-actions {
display: flex;
align-items: center;
gap: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--rr-text-secondary);
user-select: none;
}
.toggle input {
width: 16px;
height: 16px;
}
.toggle.readonly {
opacity: 0.6;
cursor: not-allowed;
}
.toggle.readonly input {
cursor: not-allowed;
}
/* Buttons */
.btn-sm {
padding: 6px 10px;
border: 1px solid var(--rr-border);
background: var(--rr-card);
color: var(--rr-text);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-sm:hover:not(:disabled) {
background: var(--rr-hover);
border-color: var(--rr-text-weak);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm.btn-primary {
background: var(--rr-accent);
color: #fff;
border-color: var(--rr-accent);
}
.btn-sm.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-icon-sm {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--rr-border);
background: var(--rr-card);
color: var(--rr-text-secondary);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.btn-icon-sm:hover:not(:disabled) {
background: var(--rr-hover);
border-color: var(--rr-text-weak);
color: var(--rr-text);
}
.btn-icon-sm.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
color: var(--rr-danger);
}
.btn-icon-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.rr-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.rr-dialog {
background: var(--rr-card);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
min-width: 360px;
max-width: 90vw;
}
.rr-dialog.small {
min-width: 320px;
}
.rr-header {
padding: 16px 20px;
border-bottom: 1px solid var(--rr-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.rr-header .title {
font-size: 15px;
font-weight: 600;
color: var(--rr-text);
}
.rr-header .close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--rr-text-secondary);
border-radius: 6px;
cursor: pointer;
}
.rr-header .close:hover {
background: var(--rr-hover);
color: var(--rr-text);
}
.rr-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.rr-footer {
padding: 16px 20px;
border-top: 1px solid var(--rr-border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Form Elements */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--rr-text-secondary);
}
.form-input,
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--rr-border);
border-radius: 8px;
background: var(--rr-card);
font-size: 14px;
color: var(--rr-text);
outline: none;
transition: all 0.15s;
}
.form-input:focus,
.form-select:focus {
border-color: var(--rr-accent);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.checkbox-group {
flex-direction: row;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--rr-text);
cursor: pointer;
}
.checkbox-label input {
width: 16px;
height: 16px;
}
.btn-cancel {
padding: 8px 16px;
border: 1px solid var(--rr-border);
background: var(--rr-card);
color: var(--rr-text);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-cancel:hover {
background: var(--rr-hover);
}
.btn-primary {
padding: 8px 16px;
border: none;
background: var(--rr-accent);
color: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -17,17 +17,10 @@ import { createQuickPanelController, type QuickPanelController } from '@/shared/
export default defineContentScript({
matches: ['<all_urls>'],
// Exclude extension pages and browser internal pages
excludeMatches: [
'chrome://*',
'chrome-extension://*',
'edge://*',
'about:*',
'moz-extension://*',
],
runAt: 'document_idle',
main() {
console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href);
let controller: QuickPanelController | null = null;
/**
@@ -55,10 +48,13 @@ export default defineContentScript({
const msg = message as { action?: string } | undefined;
if (msg?.action === 'toggle_quick_panel') {
console.log('[QuickPanelContentScript] Received toggle_quick_panel message');
try {
const ctrl = ensureController();
ctrl.toggle();
sendResponse({ success: true, visible: ctrl.isVisible() });
const visible = ctrl.isVisible();
console.log('[QuickPanelContentScript] Toggle completed, visible:', visible);
sendResponse({ success: true, visible });
} catch (err) {
console.error('[QuickPanelContentScript] Toggle error:', err);
sendResponse({ success: false, error: String(err) });
@@ -0,0 +1,11 @@
/**
* @fileoverview Shared UI Composables
* @description Composables shared between multiple UI entrypoints (Sidepanel, Builder, Popup, etc.)
*
* Note: These composables are for UI-only use. Do not import them in background scripts
* as they depend on Vue and will bloat the service worker bundle.
*/
// RR V3 RPC Client
export { useRRV3Rpc } from './useRRV3Rpc';
export type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc';
@@ -0,0 +1,504 @@
/**
* @fileoverview RR V3 Port-RPC Client Composable (Shared)
* @description RPC client for UI components to connect with Background Service Worker
*
* This composable is shared between Sidepanel, Builder, and other UI entrypoints.
*
* Responsibilities:
* - Connect to background via chrome.runtime.Port
* - Provide request/response RPC calls (with timeout and cancellation)
* - Support event stream subscription
* - Auto-reconnect with exponential backoff
*
* Design considerations:
* - MV3 service worker may be terminated due to idle, causing Port disconnect
* - Implement idempotent reconnection and subscription recovery
*/
import { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue';
import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';
import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';
import type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';
import {
RR_V3_PORT_NAME,
createRpcRequest,
isRpcEvent,
isRpcResponse,
type RpcMethod,
} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc';
// ==================== Types ====================
/** RPC request options */
export interface RpcRequestOptions {
/** Timeout in milliseconds, 0 means no timeout */
timeoutMs?: number;
/** Abort signal for cancellation */
signal?: AbortSignal;
}
/** Composable configuration */
export interface UseRRV3RpcOptions {
/** Default request timeout (ms) */
requestTimeoutMs?: number;
/** Maximum reconnect attempts */
maxReconnectAttempts?: number;
/** Base delay for reconnection (ms) */
baseReconnectDelayMs?: number;
/** Auto-connect on initialization */
autoConnect?: boolean;
/** Connection state change callback */
onConnectionChange?: (connected: boolean) => void;
/** Error callback */
onError?: (error: string) => void;
}
/** Event listener function */
type EventListener = (event: RunEvent) => void;
/** Pending request entry */
interface PendingRequest {
method: RpcMethod;
resolve: (value: JsonValue) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
/** AbortSignal reference for cleanup */
signal?: AbortSignal;
/** Abort handler for cleanup */
abortHandler?: () => void;
}
/** Composable return type */
export interface UseRRV3Rpc {
// Connection state
connected: Ref<boolean>;
connecting: Ref<boolean>;
reconnecting: Ref<boolean>;
reconnectAttempts: Ref<number>;
lastError: Ref<string | null>;
isReady: ComputedRef<boolean>;
// Diagnostics
pendingCount: Ref<number>;
subscribedRunIds: Ref<Array<RunId | null>>;
// Connection lifecycle
connect: () => Promise<boolean>;
disconnect: (reason?: string) => void;
ensureConnected: () => Promise<boolean>;
// RPC calls
request: <T extends JsonValue = JsonValue>(
method: RpcMethod,
params?: JsonObject,
options?: RpcRequestOptions,
) => Promise<T>;
// Event subscription
subscribe: (runId?: RunId | null) => Promise<boolean>;
unsubscribe: (runId?: RunId | null) => Promise<boolean>;
onEvent: (listener: EventListener) => () => void;
}
// ==================== Helpers ====================
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRunEvent(value: unknown): value is RunEvent {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.runId === 'string' &&
typeof obj.type === 'string' &&
typeof obj.seq === 'number' &&
typeof obj.ts === 'number'
);
}
// ==================== Composable ====================
/**
* RR V3 Port-RPC client
*/
export function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc {
// Configuration
const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000;
const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8;
const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500;
// Reactive state
const connected = ref(false);
const connecting = ref(false);
const reconnecting = ref(false);
const reconnectAttempts = ref(0);
const lastError = ref<string | null>(null);
const pendingCount = ref(0);
const subscribedRunIds = ref<Array<RunId | null>>([]);
// Internal state (non-reactive)
const port = shallowRef<chrome.runtime.Port | null>(null);
const pendingRequests = new Map<string, PendingRequest>();
const eventListeners = new Set<EventListener>();
const desiredSubscriptions = new Set<RunId | null>();
let connectPromise: Promise<boolean> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let manualDisconnect = false;
// Computed
const isReady = computed(() => connected.value && port.value !== null);
// ==================== Internal Methods ====================
function setError(message: string | null): void {
lastError.value = message;
if (message) options.onError?.(message);
}
function setConnected(next: boolean): void {
if (connected.value === next) return;
connected.value = next;
options.onConnectionChange?.(next);
}
function syncSubscriptionsSnapshot(): void {
const arr = Array.from(desiredSubscriptions.values());
arr.sort((a, b) => {
// Both null - equal
if (a === null && b === null) return 0;
// null comes first
if (a === null) return -1;
if (b === null) return 1;
return String(a).localeCompare(String(b));
});
subscribedRunIds.value = arr;
}
/**
* Clean up a pending request entry (timeout, abort listener)
*/
function cleanupPendingRequest(entry: PendingRequest): void {
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
entry.timeoutId = null;
}
if (entry.signal && entry.abortHandler) {
try {
entry.signal.removeEventListener('abort', entry.abortHandler);
} catch {
// Ignore - signal may be invalid
}
}
}
function rejectAllPending(reason: string): void {
const error = new Error(reason);
for (const [requestId, entry] of pendingRequests) {
cleanupPendingRequest(entry);
entry.reject(error);
pendingRequests.delete(requestId);
}
pendingCount.value = 0;
}
async function rehydrateSubscriptions(): Promise<void> {
if (!isReady.value || desiredSubscriptions.size === 0) return;
for (const runId of desiredSubscriptions) {
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.subscribe', params).catch(() => {
// Best-effort, ignore errors
});
} catch {
// Ignore
}
}
}
function scheduleReconnect(): void {
if (manualDisconnect || reconnectTimer) return;
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
reconnecting.value = false;
setError('RR V3 RPC: max reconnect attempts reached');
return;
}
reconnecting.value = true;
const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
reconnectAttempts.value += 1;
void connect().then((ok) => {
if (!ok) scheduleReconnect();
});
}, delay);
}
// ==================== Port Handlers ====================
function handlePortDisconnect(): void {
// Capture disconnect reason for debugging
const disconnectReason = chrome.runtime.lastError?.message;
const reason = disconnectReason
? `RR V3 RPC disconnected: ${disconnectReason}`
: 'RR V3 RPC disconnected';
port.value = null;
setConnected(false);
connecting.value = false;
rejectAllPending(reason);
// Update lastError for UI visibility (only on unexpected disconnect)
if (!manualDisconnect) {
setError(reason);
scheduleReconnect();
}
}
function handlePortMessage(msg: unknown): void {
// Handle RPC response
if (isRpcResponse(msg)) {
const entry = pendingRequests.get(msg.requestId);
if (!entry) return;
pendingRequests.delete(msg.requestId);
pendingCount.value = pendingRequests.size;
// Clean up timeout and abort listener
cleanupPendingRequest(entry);
if (msg.ok) {
entry.resolve(msg.result as JsonValue);
} else {
entry.reject(new Error(msg.error || `RPC error: ${entry.method}`));
}
return;
}
// Handle event push
if (isRpcEvent(msg)) {
const event = msg.event;
if (!isRunEvent(event)) return;
for (const listener of eventListeners) {
try {
listener(event);
} catch (e) {
console.error('[useRRV3Rpc] Event listener error:', e);
}
}
}
}
// ==================== Public Methods ====================
async function connect(): Promise<boolean> {
if (isReady.value) return true;
if (connectPromise) return connectPromise;
connectPromise = (async () => {
manualDisconnect = false;
connecting.value = true;
setError(null);
try {
if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {
setError('chrome.runtime.connect not available');
return false;
}
const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME });
port.value = p;
// Reset reconnect state
reconnectAttempts.value = 0;
reconnecting.value = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
p.onMessage.addListener(handlePortMessage);
p.onDisconnect.addListener(handlePortDisconnect);
setConnected(true);
// Restore subscriptions
void rehydrateSubscriptions();
return true;
} catch (error) {
setError(`Connection failed: ${toErrorMessage(error)}`);
return false;
} finally {
connecting.value = false;
connectPromise = null;
}
})();
return connectPromise;
}
function disconnect(reason?: string): void {
manualDisconnect = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnecting.value = false;
const p = port.value;
port.value = null;
setConnected(false);
connecting.value = false;
rejectAllPending(reason || 'RR V3 RPC: client disconnected');
if (p) {
try {
p.onMessage.removeListener(handlePortMessage);
p.onDisconnect.removeListener(handlePortDisconnect);
p.disconnect();
} catch {
// Ignore
}
}
}
async function ensureConnected(): Promise<boolean> {
if (isReady.value) return true;
return connect();
}
async function request<T extends JsonValue = JsonValue>(
method: RpcMethod,
params?: JsonObject,
reqOptions: RpcRequestOptions = {},
): Promise<T> {
const ready = await ensureConnected();
const p = port.value;
if (!ready || !p) {
throw new Error('RR V3 RPC: not connected');
}
const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { signal } = reqOptions;
if (signal?.aborted) {
throw new Error('RPC request already aborted');
}
const req = createRpcRequest(method, params);
return new Promise<T>((resolve, reject) => {
const entry: PendingRequest = {
method,
resolve: resolve as (value: JsonValue) => void,
reject,
timeoutId: null,
signal,
};
// Helper to complete request with cleanup
const complete = (fn: () => void) => {
pendingRequests.delete(req.requestId);
pendingCount.value = pendingRequests.size;
cleanupPendingRequest(entry);
fn();
};
// Timeout handling
if (timeoutMs > 0) {
entry.timeoutId = setTimeout(() => {
complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`)));
}, timeoutMs);
}
// Abort handling
if (signal) {
const onAbort = () => {
complete(() => reject(new Error('RPC request aborted')));
};
entry.abortHandler = onAbort;
signal.addEventListener('abort', onAbort, { once: true });
}
pendingRequests.set(req.requestId, entry);
pendingCount.value = pendingRequests.size;
try {
p.postMessage(req);
} catch (e) {
complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`)));
}
});
}
async function subscribe(runId: RunId | null = null): Promise<boolean> {
desiredSubscriptions.add(runId);
syncSubscriptionsSnapshot();
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.subscribe', params);
return true;
} catch (error) {
setError(toErrorMessage(error));
return false;
}
}
async function unsubscribe(runId: RunId | null = null): Promise<boolean> {
desiredSubscriptions.delete(runId);
syncSubscriptionsSnapshot();
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.unsubscribe', params);
return true;
} catch (error) {
setError(toErrorMessage(error));
return false;
}
}
function onEvent(listener: EventListener): () => void {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
}
// ==================== Lifecycle ====================
onUnmounted(() => {
disconnect('Component unmounted');
});
if (options.autoConnect) {
void ensureConnected();
}
return {
connected,
connecting,
reconnecting,
reconnectAttempts,
lastError,
isReady,
pendingCount,
subscribedRunIds,
connect,
disconnect,
ensureConnected,
request,
subscribe,
unsubscribe,
onEvent,
};
}
@@ -0,0 +1,14 @@
/**
* @fileoverview Shared Utilities Index
* @description Utility functions shared between UI entrypoints
*/
// Flow conversion utilities
export {
flowV2ToV3ForRpc,
flowV3ToV2ForBuilder,
isFlowV3,
isFlowV2,
extractFlowCandidates,
type FlowConversionResult,
} from './rr-flow-convert';
@@ -0,0 +1,141 @@
/**
* @fileoverview V2/V3 Flow 双向转换工具
* @description 桥接 Builder V2 Flow 类型与 V3 RPC FlowV3 类型
*
* 设计说明:
* - Builder store 目前仍使用 V2 类型 (type, version, steps)
* - RPC 层使用 V3 类型 (kind, schemaVersion, entryNodeId)
* - 本模块提供 UI 层的类型转换,封装底层转换器
*/
import type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';
import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
import {
convertFlowV2ToV3,
convertFlowV3ToV2,
} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';
// ==================== Types ====================
export interface FlowConversionResult<T> {
flow: T;
warnings: string[];
}
// ==================== V2 -> V3 (for RPC calls) ====================
/**
* 将 V2 Flow 转换为 V3 格式,用于 RPC 保存
* @param flowV2 Builder store 中的 V2 Flow
* @returns V3 Flow 和警告信息
* @throws 转换失败时抛出错误
*/
export function flowV2ToV3ForRpc(flowV2: FlowV2): FlowConversionResult<FlowV3> {
const result = convertFlowV2ToV3(flowV2 as unknown as Parameters<typeof convertFlowV2ToV3>[0]);
if (!result.success || !result.data) {
const errorMsg =
result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';
throw new Error(`V2→V3 conversion failed: ${errorMsg}`);
}
return {
flow: result.data,
warnings: result.warnings,
};
}
// ==================== V3 -> V2 (for Builder display) ====================
/**
* 将 V3 Flow 转换为 V2 格式,用于 Builder 显示和编辑
* @param flowV3 从 RPC 获取的 V3 Flow
* @returns V2 Flow 和警告信息
* @throws 转换失败时抛出错误
*/
export function flowV3ToV2ForBuilder(flowV3: FlowV3): FlowConversionResult<FlowV2> {
const result = convertFlowV3ToV2(flowV3);
if (!result.success || !result.data) {
const errorMsg =
result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';
throw new Error(`V3→V2 conversion failed: ${errorMsg}`);
}
return {
flow: result.data as unknown as FlowV2,
warnings: result.warnings,
};
}
// ==================== Type Guards ====================
/**
* 判断是否为 V3 Flow
* @description 用于导入时判断 JSON 格式
*/
export function isFlowV3(value: unknown): value is FlowV3 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
return (
obj.schemaVersion === 3 &&
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
typeof obj.entryNodeId === 'string' &&
Array.isArray(obj.nodes)
);
}
/**
* 判断是否为 V2 Flow
* @description 用于导入时判断 JSON 格式
*/
export function isFlowV2(value: unknown): value is FlowV2 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
// V2 有 version 字段(数字),且没有 schemaVersion
typeof obj.version === 'number' &&
obj.schemaVersion === undefined &&
// V2 可能有 steps 或 nodes
(Array.isArray(obj.steps) || Array.isArray(obj.nodes))
);
}
// ==================== Import Helpers ====================
/**
* 从导入的 JSON 中提取 Flow 候选列表
* @description 支持单个 Flow、Flow 数组、或 { flows: Flow[] } 格式
*/
export function extractFlowCandidates(parsed: unknown): unknown[] {
// 数组格式
if (Array.isArray(parsed)) {
return parsed;
}
// 对象格式
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
// { flows: [...] } 格式
if (Array.isArray(obj.flows)) {
return obj.flows;
}
// 单个 Flow 对象
if (obj.id && (Array.isArray(obj.steps) || Array.isArray(obj.nodes))) {
return [obj];
}
}
return [];
}
@@ -57,7 +57,7 @@
<!-- Dismiss button -->
<button
type="button"
class="p-1 flex-shrink-0 ac-btn ac-focus-ring"
class="p-1 flex-shrink-0 ac-btn ac-focus-ring cursor-pointer"
:style="{
color: 'var(--ac-danger)',
borderRadius: 'var(--ac-radius-button)',
@@ -49,7 +49,7 @@
</div>
<!-- Remove button (appears on hover) -->
<button
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
:style="{
backgroundColor: 'var(--ac-error)',
color: 'white',
@@ -133,7 +133,7 @@
<button
v-if="showExpandButton"
type="button"
class="absolute top-2 right-2 p-1.5 transition-all hover:scale-105"
class="absolute top-2 right-2 p-1.5 transition-all hover:scale-105 cursor-pointer"
:style="expandButtonStyle"
title="Expand editor"
@click="openDrawer"
@@ -283,7 +283,7 @@
<!-- Primary Action Button: Send (idle) / Stop (loading) -->
<button
type="button"
class="p-1.5 transition-colors"
class="p-1 transition-colors cursor-pointer"
:style="primaryActionButtonStyle"
:disabled="primaryActionDisabled"
:title="isRequestActive ? 'Stop' : 'Send'"
@@ -291,11 +291,11 @@
@click="handlePrimaryAction"
>
<!-- Stop icon (square) when request is active -->
<svg v-if="isRequestActive" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<svg v-if="isRequestActive" class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
<!-- Send icon (arrow up) when idle -->
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg v-else class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -199,7 +199,7 @@
<!-- Save Button -->
<div class="px-3 py-2">
<button
class="w-full px-3 py-1.5 text-xs rounded transition-colors hover:opacity-90"
class="w-full px-3 py-1.5 text-xs rounded transition-colors hover:opacity-90 cursor-pointer"
:style="{
backgroundColor: 'var(--ac-accent, #c87941)',
color: 'var(--ac-accent-contrast, #ffffff)',
@@ -19,7 +19,7 @@
<!-- Edit button (placeholder, appears on hover) -->
<button
class="opacity-0 group-hover:opacity-100 transition-opacity p-1"
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 cursor-pointer"
:style="{ color: 'var(--ac-text-subtle)' }"
title="Edit (coming soon)"
>
@@ -40,7 +40,7 @@
v-for="attachment in thread.attachments"
:key="`${attachment.messageId}:${attachment.index}`"
type="button"
class="relative group/thumb w-16 h-16 rounded-lg overflow-hidden cursor-pointer transition-opacity hover:opacity-90"
class="relative group/thumb w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
border: 'var(--ac-border-width) solid var(--ac-border)',
@@ -114,7 +114,7 @@
<!-- Close button -->
<button
type="button"
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20"
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer"
:style="{ color: 'white' }"
aria-label="Close image preview"
@click="closeViewer"
@@ -125,7 +125,7 @@
<!-- Open Project Button -->
<button
v-if="!isEditing"
class="p-1.5 rounded-md transition-colors"
class="p-1.5 rounded-md transition-colors cursor-pointer"
:style="actionButtonStyle"
title="Open project"
@click.stop="handleOpenProject"
@@ -149,7 +149,7 @@
<!-- Rename Button -->
<button
v-if="!isEditing"
class="p-1.5 rounded-md transition-colors"
class="p-1.5 rounded-md transition-colors cursor-pointer"
:style="actionButtonStyle"
title="Rename"
@click.stop="startRename"
@@ -166,7 +166,7 @@
<!-- Delete Button -->
<button
v-if="!isEditing"
class="p-1.5 rounded-md transition-colors"
class="p-1.5 rounded-md transition-colors cursor-pointer"
:style="deleteButtonStyle"
title="Delete"
@click.stop="handleDelete"
@@ -104,7 +104,7 @@
<!-- Rename Button -->
<button
v-if="editingSessionId !== session.id"
class="p-1 ac-btn"
class="p-1 ac-btn cursor-pointer"
:style="{
color: 'var(--ac-text-muted, #6e6e6e)',
borderRadius: 'var(--ac-radius-button)',
@@ -123,7 +123,7 @@
</button>
<!-- Delete Button -->
<button
class="p-1 ac-btn"
class="p-1 ac-btn cursor-pointer"
:style="{
color: 'var(--ac-danger, #dc2626)',
borderRadius: 'var(--ac-radius-button)',
@@ -30,7 +30,7 @@
<!-- New Session Button -->
<button
class="flex-shrink-0 px-3 py-2 text-sm font-medium"
class="flex-shrink-0 px-3 py-2 text-sm font-medium cursor-pointer"
:style="newButtonStyle"
:disabled="isCreating"
@click="handleNewSession"
@@ -94,7 +94,7 @@
</div>
<button
v-if="!searchQuery"
class="px-4 py-2 text-sm font-medium"
class="px-4 py-2 text-sm font-medium cursor-pointer"
:style="newButtonStyle"
@click="handleNewSession"
>
@@ -1,11 +1,11 @@
<template>
<div class="flex items-center justify-between w-full">
<!-- Brand / Context -->
<div class="flex items-center gap-3 overflow-hidden">
<div class="flex items-center gap-2 overflow-hidden -ml-1">
<!-- Back Button (when in chat view) -->
<button
v-if="showBackButton"
class="flex items-center justify-center w-8 h-8 -ml-3 flex-shrink-0 ac-btn"
class="flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn"
:style="{
color: 'var(--ac-text-muted)',
borderRadius: 'var(--ac-radius-button)',
@@ -25,7 +25,7 @@
<!-- Brand -->
<h1
class="text-lg font-medium tracking-tight flex-shrink-0 ml-[-10px]"
class="text-lg font-medium tracking-tight flex-shrink-0"
:style="{
fontFamily: 'var(--ac-font-heading)',
color: 'var(--ac-text)',
@@ -134,7 +134,7 @@
<div class="flex items-center gap-2">
<button
type="button"
class="px-3 py-2 text-xs font-medium"
class="px-3 py-2 text-xs font-medium cursor-pointer"
:style="{
backgroundColor: 'var(--ac-chip-bg)',
color: 'var(--ac-chip-text)',
@@ -148,7 +148,7 @@
</button>
<button
type="button"
class="px-3 py-2 text-xs font-medium"
class="px-3 py-2 text-xs font-medium cursor-pointer"
:style="{
backgroundColor: 'transparent',
color: 'var(--ac-text-muted)',
@@ -230,7 +230,7 @@
<div class="flex items-center gap-1.5 flex-wrap justify-end">
<button
type="button"
class="px-2 py-1 text-[11px] font-medium"
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
:style="chipStyle"
:disabled="isClearing || selectableProjectIds.length === 0"
@click="selectAll"
@@ -239,7 +239,7 @@
</button>
<button
type="button"
class="px-2 py-1 text-[11px] font-medium"
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
:style="chipStyle"
:disabled="isClearing || selectableProjectIds.length === 0"
@click="invertSelection"
@@ -248,7 +248,7 @@
</button>
<button
type="button"
class="px-2 py-1 text-[11px] font-medium"
class="px-2 py-1 text-[11px] font-medium cursor-pointer"
:style="chipStyle"
:disabled="isClearing || selectedCount === 0"
@click="clearSelection"
@@ -349,7 +349,7 @@
<button
type="button"
class="px-3 py-2 text-xs font-semibold rounded-lg flex-shrink-0"
class="px-3 py-2 text-xs font-semibold rounded-lg flex-shrink-0 cursor-pointer"
:disabled="!canClear"
:style="clearButtonStyle"
@click="clearSelected"
@@ -31,7 +31,7 @@
<button
type="button"
class="p-1.5 transition-colors hover:opacity-80"
class="p-1.5 transition-colors hover:opacity-80 cursor-pointer"
:style="closeButtonStyle"
aria-label="Close expanded editor"
@click="emit('close')"
@@ -77,7 +77,7 @@
<!-- Remove button -->
<button
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
:style="removeButtonStyle"
title="Remove image"
@click="emit('attachment:remove', index)"
@@ -138,7 +138,7 @@
<!-- Cancel button: Show when request is active (not just streaming) -->
<button
v-if="isRequestActive && canCancel && !sending"
class="px-3 py-1.5 text-xs transition-colors"
class="px-3 py-1.5 text-xs transition-colors cursor-pointer"
:style="cancelButtonStyle"
:disabled="cancelling"
@click="emit('cancel')"
@@ -148,7 +148,7 @@
<!-- Send button -->
<button
class="px-3 py-1.5 text-xs font-medium transition-colors"
class="px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer"
:style="sendButtonStyle"
:disabled="!canSend || sending"
@click="handleSubmit"
@@ -102,7 +102,7 @@
<button
v-show="isHovering"
type="button"
class="flex items-center justify-center w-4 h-4 -ml-1 mr-1 rounded-full transition-colors"
class="flex items-center justify-center w-4 h-4 -ml-1 mr-1 rounded-full transition-colors cursor-pointer"
:style="revertButtonStyle"
:aria-label="`Revert changes to ${element.label}`"
:title="`Revert all changes to ${element.label}`"
@@ -24,7 +24,7 @@
>
<button
type="button"
class="px-2 py-0.5 text-[10px] transition-colors"
class="px-2 py-0.5 text-[10px] transition-colors cursor-pointer"
:style="includeButtonStyle"
:aria-pressed="viewMode === 'include'"
@click="viewMode = 'include'"
@@ -33,7 +33,7 @@
</button>
<button
type="button"
class="px-2 py-0.5 text-[10px] transition-colors"
class="px-2 py-0.5 text-[10px] transition-colors cursor-pointer"
:style="excludeButtonStyle"
:aria-pressed="viewMode === 'exclude'"
@click="viewMode = 'exclude'"
@@ -168,7 +168,6 @@ function formatLine(text: string): string {
.thinking-section {
display: block;
margin: 8px 0;
border-left: 2px solid var(--ac-accent);
padding-left: 12px;
background: var(--ac-surface-muted);
border-radius: var(--ac-radius-inner);
@@ -111,7 +111,7 @@
</div>
<button
v-if="isDetailsTruncated"
class="w-full px-3 py-1 text-[10px] text-left"
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
color: 'var(--ac-link)',
@@ -135,7 +135,7 @@
</div>
<button
v-if="isDetailsTruncated"
class="w-full px-3 py-1 text-[10px] text-left"
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
color: 'var(--ac-link)',
@@ -32,7 +32,7 @@
v-for="attachment in item.attachments"
:key="`${attachment.messageId}:${attachment.index}`"
type="button"
class="relative group w-16 h-16 rounded-lg overflow-hidden cursor-pointer transition-opacity hover:opacity-90"
class="relative group w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
border: 'var(--ac-border-width) solid var(--ac-border)',
@@ -102,7 +102,7 @@
<!-- Close button -->
<button
type="button"
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20"
class="absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer"
:style="{ color: 'white' }"
aria-label="Close image preview"
@click="closeViewer"
@@ -458,8 +458,9 @@ export function useAgentSessions(options: UseAgentSessionsOptions) {
}
/**
* Update session preview locally (without server call).
* Used when sending the first message to update the display immediately.
* Update session preview and updatedAt locally (without server call).
* Used when sending a message to update the display immediately.
* Always updates updatedAt so the session moves to the top of the list.
* @param sessionId - The session to update
* @param preview - The preview text (user's raw input)
* @param previewMeta - Optional structured metadata for special rendering (e.g., web editor apply chip)
@@ -474,23 +475,30 @@ export function useAgentSessions(options: UseAgentSessionsOptions) {
const trimmed = preview.trim().replace(/\s+/g, ' ');
const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed;
// Always update updatedAt to move session to top of list
const now = new Date().toISOString();
// Update in current project sessions
const index = sessions.value.findIndex((s) => s.id === sessionId);
if (index !== -1 && !sessions.value[index].preview) {
if (index !== -1) {
sessions.value[index] = {
...sessions.value[index],
preview: truncated,
previewMeta,
// Only update preview if not already set
preview: sessions.value[index].preview || truncated,
previewMeta: sessions.value[index].previewMeta || previewMeta,
// Always update timestamp so session moves to top
updatedAt: now,
};
}
// Also update in allSessions for global list view
const allIndex = allSessions.value.findIndex((s) => s.id === sessionId);
if (allIndex !== -1 && !allSessions.value[allIndex].preview) {
if (allIndex !== -1) {
allSessions.value[allIndex] = {
...allSessions.value[allIndex],
preview: truncated,
previewMeta,
preview: allSessions.value[allIndex].preview || truncated,
previewMeta: allSessions.value[allIndex].previewMeta || previewMeta,
updatedAt: now,
};
}
}
@@ -1,502 +1,11 @@
/**
* @fileoverview RR V3 Port-RPC Client Composable
* @description RPC client for Sidepanel UI to connect with Background Service Worker
*
* Responsibilities:
* - Connect to background via chrome.runtime.Port
* - Provide request/response RPC calls (with timeout and cancellation)
* - Support event stream subscription
* - Auto-reconnect with exponential backoff
*
* Design considerations:
* - MV3 service worker may be terminated due to idle, causing Port disconnect
* - Implement idempotent reconnection and subscription recovery
* @fileoverview Re-export shared useRRV3Rpc composable
* @description This file re-exports the shared composable for backward compatibility
*/
import { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue';
import type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';
import type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';
import type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';
import {
RR_V3_PORT_NAME,
createRpcRequest,
isRpcEvent,
isRpcResponse,
type RpcMethod,
} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc';
// ==================== Types ====================
/** RPC request options */
export interface RpcRequestOptions {
/** Timeout in milliseconds, 0 means no timeout */
timeoutMs?: number;
/** Abort signal for cancellation */
signal?: AbortSignal;
}
/** Composable configuration */
export interface UseRRV3RpcOptions {
/** Default request timeout (ms) */
requestTimeoutMs?: number;
/** Maximum reconnect attempts */
maxReconnectAttempts?: number;
/** Base delay for reconnection (ms) */
baseReconnectDelayMs?: number;
/** Auto-connect on initialization */
autoConnect?: boolean;
/** Connection state change callback */
onConnectionChange?: (connected: boolean) => void;
/** Error callback */
onError?: (error: string) => void;
}
/** Event listener function */
type EventListener = (event: RunEvent) => void;
/** Pending request entry */
interface PendingRequest {
method: RpcMethod;
resolve: (value: JsonValue) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
/** AbortSignal reference for cleanup */
signal?: AbortSignal;
/** Abort handler for cleanup */
abortHandler?: () => void;
}
/** Composable return type */
export interface UseRRV3Rpc {
// Connection state
connected: Ref<boolean>;
connecting: Ref<boolean>;
reconnecting: Ref<boolean>;
reconnectAttempts: Ref<number>;
lastError: Ref<string | null>;
isReady: ComputedRef<boolean>;
// Diagnostics
pendingCount: Ref<number>;
subscribedRunIds: Ref<Array<RunId | null>>;
// Connection lifecycle
connect: () => Promise<boolean>;
disconnect: (reason?: string) => void;
ensureConnected: () => Promise<boolean>;
// RPC calls
request: <T extends JsonValue = JsonValue>(
method: RpcMethod,
params?: JsonObject,
options?: RpcRequestOptions,
) => Promise<T>;
// Event subscription
subscribe: (runId?: RunId | null) => Promise<boolean>;
unsubscribe: (runId?: RunId | null) => Promise<boolean>;
onEvent: (listener: EventListener) => () => void;
}
// ==================== Helpers ====================
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRunEvent(value: unknown): value is RunEvent {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.runId === 'string' &&
typeof obj.type === 'string' &&
typeof obj.seq === 'number' &&
typeof obj.ts === 'number'
);
}
// ==================== Composable ====================
/**
* RR V3 Port-RPC client
*/
export function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc {
// Configuration
const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000;
const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8;
const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500;
// Reactive state
const connected = ref(false);
const connecting = ref(false);
const reconnecting = ref(false);
const reconnectAttempts = ref(0);
const lastError = ref<string | null>(null);
const pendingCount = ref(0);
const subscribedRunIds = ref<Array<RunId | null>>([]);
// Internal state (non-reactive)
const port = shallowRef<chrome.runtime.Port | null>(null);
const pendingRequests = new Map<string, PendingRequest>();
const eventListeners = new Set<EventListener>();
const desiredSubscriptions = new Set<RunId | null>();
let connectPromise: Promise<boolean> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let manualDisconnect = false;
// Computed
const isReady = computed(() => connected.value && port.value !== null);
// ==================== Internal Methods ====================
function setError(message: string | null): void {
lastError.value = message;
if (message) options.onError?.(message);
}
function setConnected(next: boolean): void {
if (connected.value === next) return;
connected.value = next;
options.onConnectionChange?.(next);
}
function syncSubscriptionsSnapshot(): void {
const arr = Array.from(desiredSubscriptions.values());
arr.sort((a, b) => {
// Both null - equal
if (a === null && b === null) return 0;
// null comes first
if (a === null) return -1;
if (b === null) return 1;
return String(a).localeCompare(String(b));
});
subscribedRunIds.value = arr;
}
/**
* Clean up a pending request entry (timeout, abort listener)
*/
function cleanupPendingRequest(entry: PendingRequest): void {
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
entry.timeoutId = null;
}
if (entry.signal && entry.abortHandler) {
try {
entry.signal.removeEventListener('abort', entry.abortHandler);
} catch {
// Ignore - signal may be invalid
}
}
}
function rejectAllPending(reason: string): void {
const error = new Error(reason);
for (const [requestId, entry] of pendingRequests) {
cleanupPendingRequest(entry);
entry.reject(error);
pendingRequests.delete(requestId);
}
pendingCount.value = 0;
}
async function rehydrateSubscriptions(): Promise<void> {
if (!isReady.value || desiredSubscriptions.size === 0) return;
for (const runId of desiredSubscriptions) {
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.subscribe', params).catch(() => {
// Best-effort, ignore errors
});
} catch {
// Ignore
}
}
}
function scheduleReconnect(): void {
if (manualDisconnect || reconnectTimer) return;
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
reconnecting.value = false;
setError('RR V3 RPC: max reconnect attempts reached');
return;
}
reconnecting.value = true;
const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
reconnectAttempts.value += 1;
void connect().then((ok) => {
if (!ok) scheduleReconnect();
});
}, delay);
}
// ==================== Port Handlers ====================
function handlePortDisconnect(): void {
// Capture disconnect reason for debugging
const disconnectReason = chrome.runtime.lastError?.message;
const reason = disconnectReason
? `RR V3 RPC disconnected: ${disconnectReason}`
: 'RR V3 RPC disconnected';
port.value = null;
setConnected(false);
connecting.value = false;
rejectAllPending(reason);
// Update lastError for UI visibility (only on unexpected disconnect)
if (!manualDisconnect) {
setError(reason);
scheduleReconnect();
}
}
function handlePortMessage(msg: unknown): void {
// Handle RPC response
if (isRpcResponse(msg)) {
const entry = pendingRequests.get(msg.requestId);
if (!entry) return;
pendingRequests.delete(msg.requestId);
pendingCount.value = pendingRequests.size;
// Clean up timeout and abort listener
cleanupPendingRequest(entry);
if (msg.ok) {
entry.resolve(msg.result as JsonValue);
} else {
entry.reject(new Error(msg.error || `RPC error: ${entry.method}`));
}
return;
}
// Handle event push
if (isRpcEvent(msg)) {
const event = msg.event;
if (!isRunEvent(event)) return;
for (const listener of eventListeners) {
try {
listener(event);
} catch (e) {
console.error('[useRRV3Rpc] Event listener error:', e);
}
}
}
}
// ==================== Public Methods ====================
async function connect(): Promise<boolean> {
if (isReady.value) return true;
if (connectPromise) return connectPromise;
connectPromise = (async () => {
manualDisconnect = false;
connecting.value = true;
setError(null);
try {
if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {
setError('chrome.runtime.connect not available');
return false;
}
const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME });
port.value = p;
// 重置重连状态
reconnectAttempts.value = 0;
reconnecting.value = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
p.onMessage.addListener(handlePortMessage);
p.onDisconnect.addListener(handlePortDisconnect);
setConnected(true);
// Restore subscriptions
void rehydrateSubscriptions();
return true;
} catch (error) {
setError(`Connection failed: ${toErrorMessage(error)}`);
return false;
} finally {
connecting.value = false;
connectPromise = null;
}
})();
return connectPromise;
}
function disconnect(reason?: string): void {
manualDisconnect = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnecting.value = false;
const p = port.value;
port.value = null;
setConnected(false);
connecting.value = false;
rejectAllPending(reason || 'RR V3 RPC: client disconnected');
if (p) {
try {
p.onMessage.removeListener(handlePortMessage);
p.onDisconnect.removeListener(handlePortDisconnect);
p.disconnect();
} catch {
// Ignore
}
}
}
async function ensureConnected(): Promise<boolean> {
if (isReady.value) return true;
return connect();
}
async function request<T extends JsonValue = JsonValue>(
method: RpcMethod,
params?: JsonObject,
reqOptions: RpcRequestOptions = {},
): Promise<T> {
const ready = await ensureConnected();
const p = port.value;
if (!ready || !p) {
throw new Error('RR V3 RPC: not connected');
}
const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const { signal } = reqOptions;
if (signal?.aborted) {
throw new Error('RPC request already aborted');
}
const req = createRpcRequest(method, params);
return new Promise<T>((resolve, reject) => {
const entry: PendingRequest = {
method,
resolve: resolve as (value: JsonValue) => void,
reject,
timeoutId: null,
signal,
};
// Helper to complete request with cleanup
const complete = (fn: () => void) => {
pendingRequests.delete(req.requestId);
pendingCount.value = pendingRequests.size;
cleanupPendingRequest(entry);
fn();
};
// Timeout handling
if (timeoutMs > 0) {
entry.timeoutId = setTimeout(() => {
complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`)));
}, timeoutMs);
}
// Abort handling
if (signal) {
const onAbort = () => {
complete(() => reject(new Error('RPC request aborted')));
};
entry.abortHandler = onAbort;
signal.addEventListener('abort', onAbort, { once: true });
}
pendingRequests.set(req.requestId, entry);
pendingCount.value = pendingRequests.size;
try {
p.postMessage(req);
} catch (e) {
complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`)));
}
});
}
async function subscribe(runId: RunId | null = null): Promise<boolean> {
desiredSubscriptions.add(runId);
syncSubscriptionsSnapshot();
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.subscribe', params);
return true;
} catch (error) {
setError(toErrorMessage(error));
return false;
}
}
async function unsubscribe(runId: RunId | null = null): Promise<boolean> {
desiredSubscriptions.delete(runId);
syncSubscriptionsSnapshot();
try {
const params: JsonObject = runId === null ? {} : { runId };
await request('rr_v3.unsubscribe', params);
return true;
} catch (error) {
setError(toErrorMessage(error));
return false;
}
}
function onEvent(listener: EventListener): () => void {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
}
// ==================== Lifecycle ====================
onUnmounted(() => {
disconnect('Component unmounted');
});
if (options.autoConnect) {
void ensureConnected();
}
return {
connected,
connecting,
reconnecting,
reconnectAttempts,
lastError,
isReady,
pendingCount,
subscribedRunIds,
connect,
disconnect,
ensureConnected,
request,
subscribe,
unsubscribe,
onEvent,
};
}
export {
useRRV3Rpc,
type UseRRV3Rpc,
type UseRRV3RpcOptions,
type RpcRequestOptions,
} from '@/entrypoints/shared/composables/useRRV3Rpc';
@@ -645,6 +645,7 @@
/* Button/interactive element base */
.agent-theme .ac-btn {
cursor: pointer;
transition:
background-color var(--ac-motion-fast),
color var(--ac-motion-fast);
@@ -656,6 +657,7 @@
/* Menu item */
.agent-theme .ac-menu-item {
cursor: pointer;
transition: background-color var(--ac-motion-fast);
}
@@ -694,24 +696,32 @@
Loading Animation - Shimmer Text & Scribble Icon
============================================================ */
/* 文案 shimmer 渐变动画 */
/* 文案 shimmer 渐变动画 - 光从左到右扫过效果 */
.agent-theme .text-shimmer {
display: inline-block;
background: linear-gradient(
90deg,
var(--ac-accent, #d97757) 0%,
var(--ac-accent-hover, #ffcab0) 50%,
var(--ac-accent, #d97757) 40%,
#ffe0d0 50%,
var(--ac-accent, #d97757) 60%,
var(--ac-accent, #d97757) 100%
);
background-size: 200% auto;
background-size: 250% 100%;
background-repeat: no-repeat;
color: transparent;
-webkit-background-clip: text;
background-clip: text;
animation: ac-shimmer 3s linear infinite;
-webkit-text-fill-color: transparent;
animation: ac-shimmer 1.8s ease-in-out infinite;
}
@keyframes ac-shimmer {
to {
background-position: 200% center;
0% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@@ -12,7 +12,7 @@
* @module props-bridge
*/
import type { ElementLocator } from '@/common/web-editor-types';
import type { DebugSource, ElementLocator } from '@/common/web-editor-types';
// =============================================================================
// Types - Hook Status
@@ -223,7 +223,11 @@ export interface PropsResponseData {
hookStatus?: HookStatus;
needsRefresh?: boolean;
framework?: FrameworkType;
/** Framework version (e.g., "18.2.0" for React, "3.4.21" for Vue) */
frameworkVersion?: string;
componentName?: string;
/** Source file location for the component (React _debugSource / Vue data-v-inspector) */
debugSource?: DebugSource;
props?: SerializedProps;
capabilities?: PropsCapabilities;
meta?: Record<string, unknown>;
@@ -67,9 +67,16 @@ function isDangerousPropKey(key: string): boolean {
return DANGEROUS_PROP_KEYS.has(String(key ?? '').trim());
}
function formatFramework(framework: FrameworkType | undefined): string {
if (framework === 'react') return 'React';
if (framework === 'vue') return 'Vue';
function formatFramework(framework: FrameworkType | undefined, version?: string): string {
// Only show version for known frameworks to avoid "Unknown x.y.z" display
if (framework === 'react') {
const trimmedVersion = version?.trim();
return trimmedVersion ? `React ${trimmedVersion}` : 'React';
}
if (framework === 'vue') {
const trimmedVersion = version?.trim();
return trimmedVersion ? `Vue ${trimmedVersion}` : 'Vue';
}
return 'Unknown';
}
@@ -77,6 +84,26 @@ function formatHookStatus(hookStatus: HookStatus | undefined): string {
return hookStatus ? String(hookStatus) : '';
}
/**
* Format debug source for display.
* Returns empty string if source is invalid/missing.
*/
function formatDebugSource(source: unknown): string {
if (!source || typeof source !== 'object') return '';
const rec = source as Record<string, unknown>;
const file = typeof rec.file === 'string' ? rec.file.trim() : '';
if (!file) return '';
const lineRaw = Number(rec.line);
const columnRaw = Number(rec.column);
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;
const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;
if (!line) return file;
return column ? `${file}:${line}:${column}` : `${file}:${line}`;
}
function formatSerializedValue(value: SerializedValue): string {
switch (value.kind) {
case 'null':
@@ -275,6 +302,39 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
const { container, propsBridge } = options;
const disposer = new Disposer();
// ==========================================================================
// Tooltip - fixed position at shadow root level to avoid overflow clipping
// ==========================================================================
const tooltip = document.createElement('div');
tooltip.className = 'we-tooltip';
tooltip.hidden = true;
const rootNode = container.getRootNode();
if (rootNode instanceof ShadowRoot) {
rootNode.appendChild(tooltip);
} else {
document.body.appendChild(tooltip);
}
disposer.add(() => tooltip.remove());
function showTooltip(el: Element): void {
const text = el.getAttribute('data-tip');
if (!text) {
tooltip.hidden = true;
return;
}
const rect = el.getBoundingClientRect();
tooltip.textContent = text;
tooltip.style.left = `${rect.left + rect.width / 2}px`;
tooltip.style.top = `${rect.bottom + 4}px`;
tooltip.hidden = false;
}
function hideTooltip(): void {
tooltip.hidden = true;
}
// State
let currentTarget: Element | null = null;
let currentLocator: ElementLocator | null = null;
@@ -303,6 +363,9 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
const metaTitleRow = document.createElement('div');
metaTitleRow.className = 'we-props-meta-title';
const titleLeft = document.createElement('div');
titleLeft.className = 'we-props-title-left';
const componentEl = document.createElement('div');
componentEl.className = 'we-props-component';
componentEl.textContent = 'Props';
@@ -311,7 +374,34 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
frameworkEl.className = 'we-props-badge';
frameworkEl.textContent = 'Unknown';
metaTitleRow.append(componentEl, frameworkEl);
titleLeft.append(componentEl, frameworkEl);
// Action buttons in title row (icon style)
const titleActions = document.createElement('div');
titleActions.className = 'we-props-title-actions';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'we-props-action-btn';
refreshBtn.dataset.tip = 'Refresh';
refreshBtn.setAttribute('aria-label', 'Refresh props');
// Refresh icon (circular arrow)
refreshBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 7C11.5 9.48528 9.48528 11.5 7 11.5C4.51472 11.5 2.5 9.48528 2.5 7C2.5 4.51472 4.51472 2.5 7 2.5C8.5 2.5 9.83 3.25 10.6 4.4M10.6 2V4.4H8.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'we-props-action-btn';
resetBtn.dataset.tip = 'Reset';
resetBtn.setAttribute('aria-label', 'Reset props changes');
// Reset icon (undo arrow)
resetBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 5.5H8.5C10.1569 5.5 11.5 6.84315 11.5 8.5C11.5 10.1569 10.1569 11.5 8.5 11.5H7M3 5.5L5.5 3M3 5.5L5.5 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
titleActions.append(refreshBtn, resetBtn);
metaTitleRow.append(titleLeft, titleActions);
const statusEl = document.createElement('div');
statusEl.className = 'we-props-status';
@@ -324,21 +414,32 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
errorEl.className = 'we-props-error';
errorEl.hidden = true;
const actionsRow = document.createElement('div');
actionsRow.className = 'we-props-actions';
// Source row - shows component source file location with "Open in VSCode" button
const sourceRow = document.createElement('div');
sourceRow.className = 'we-props-source';
sourceRow.hidden = true;
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'we-btn';
refreshBtn.textContent = 'Refresh';
const sourceLabelEl = document.createElement('span');
sourceLabelEl.className = 'we-props-source-label';
sourceLabelEl.textContent = 'Source';
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'we-btn';
resetBtn.textContent = 'Reset';
const sourcePathEl = document.createElement('span');
sourcePathEl.className = 'we-props-source-path';
sourcePathEl.title = ''; // Will be set to full path on render
actionsRow.append(refreshBtn, resetBtn);
meta.append(metaTitleRow, statusEl, warningEl, errorEl, actionsRow);
const openSourceBtn = document.createElement('button');
openSourceBtn.type = 'button';
openSourceBtn.className = 'we-props-source-btn';
openSourceBtn.dataset.tip = 'Open in VSCode';
openSourceBtn.setAttribute('aria-label', 'Open in VSCode');
// Simple arrow pointing to top-right (external link style)
openSourceBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 2.5H9.5V8.5M9 3L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
sourceRow.append(sourceLabelEl, sourcePathEl, openSourceBtn);
meta.append(metaTitleRow, statusEl, warningEl, errorEl, sourceRow);
// List section
const list = document.createElement('div');
@@ -417,10 +518,11 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
function renderMeta(): void {
const hasTarget = Boolean(currentTarget && currentTarget.isConnected);
const framework = lastData?.framework;
const frameworkVersion = lastData?.frameworkVersion;
const componentName = lastData?.componentName;
componentEl.textContent = componentName || 'Props';
frameworkEl.textContent = formatFramework(framework);
frameworkEl.textContent = formatFramework(framework, frameworkVersion);
statusEl.textContent = hasTarget
? buildStatusLine(loading, lastData, lastError)
@@ -448,13 +550,19 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
errorEl.hidden = !lastError;
errorEl.textContent = lastError ?? '';
// Update refresh button text based on needsRefresh state
// Only show "Enable & Reload" for hook issues that can benefit from early injection
// Source display - show component file location with Open button
const sourceText = hasTarget ? formatDebugSource(lastData?.debugSource) : '';
sourceRow.hidden = !sourceText;
sourcePathEl.textContent = sourceText;
sourcePathEl.title = sourceText; // Show full path on hover
openSourceBtn.disabled = !sourceText || loading;
// Update refresh button state and tooltip
const hookStatus = lastData?.hookStatus;
const canBenefitFromEarlyInjection =
hookStatus === 'HOOK_MISSING' || hookStatus === 'HOOK_PRESENT_NO_RENDERERS';
const showEnableReload = lastData?.needsRefresh && canBenefitFromEarlyInjection;
refreshBtn.textContent = showEnableReload ? 'Enable & Reload' : 'Refresh';
refreshBtn.dataset.tip = showEnableReload ? 'Enable & Reload' : 'Refresh';
refreshBtn.disabled = !hasTarget || loading;
resetBtn.disabled = !hasTarget || loading || !getCanWrite(lastData);
}
@@ -467,16 +575,24 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
if (!hasTarget) {
emptyState.hidden = false;
emptyState.classList.remove('we-loading');
emptyState.textContent = 'Select an element to view props.';
return;
}
if (loading) {
emptyState.hidden = false;
emptyState.textContent = 'Loading props…';
emptyState.classList.add('we-loading');
// Spinner icon (thin stroke) + text
emptyState.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="animation: we-spin 0.8s linear infinite;">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-dasharray="20 14" />
</svg><span>Loading props</span>`;
return;
}
// Remove loading class when not loading
emptyState.classList.remove('we-loading');
const canRead = getCanRead(data);
if (!canRead) {
emptyState.hidden = false;
@@ -841,10 +957,50 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
}
}
/**
* Send message to background to open source file in VSCode.
*/
async function openSourceInVSCode(): Promise<void> {
if (disposer.isDisposed) return;
const debugSource = lastData?.debugSource;
if (!debugSource || !formatDebugSource(debugSource)) return;
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
lastError = 'Chrome runtime API not available';
renderMeta();
return;
}
try {
const resp = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE,
payload: { debugSource },
});
if (resp?.success === false) {
lastError = resp?.error ?? 'Failed to open source in VSCode';
renderMeta();
}
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
renderMeta();
}
}
// ==========================================================================
// Event Handlers
// ==========================================================================
// Tooltip events - bind directly to elements with data-tip
const bindTooltip = (el: HTMLElement) => {
disposer.listen(el, 'mouseenter', () => showTooltip(el));
disposer.listen(el, 'mouseleave', hideTooltip);
};
bindTooltip(refreshBtn);
bindTooltip(resetBtn);
bindTooltip(openSourceBtn);
disposer.listen(refreshBtn, 'click', (e) => {
e.preventDefault();
clearAllPendingWrites();
@@ -868,6 +1024,11 @@ export function createPropsPanel(options: PropsPanelOptions): PropsPanel {
void resetOverrides();
});
disposer.listen(openSourceBtn, 'click', (e) => {
e.preventDefault();
void openSourceInVSCode();
});
// Delegate input events within the list
disposer.listen(rows, 'input', (e: Event) => {
const target = e.target as HTMLElement | null;
@@ -3072,11 +3072,125 @@ const SHADOW_HOST_STYLES = /* css */ `
padding: 6px 8px;
}
.we-props-actions {
.we-props-source {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
padding: 4px 0;
}
.we-props-source[hidden] {
display: none;
}
.we-props-source-label {
flex: 0 0 auto;
color: #64748b;
font-weight: 500;
}
.we-props-source-path {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--we-text-primary);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.we-btn-small {
padding: 2px 8px;
font-size: 11px;
}
/* Source open button - minimal link style */
.we-props-source-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
margin-left: 2px;
border: none;
background: none;
color: #64748b;
cursor: pointer;
transition: color 0.12s ease;
}
.we-props-source-btn:hover:not(:disabled) {
color: #3b82f6;
}
.we-props-source-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.we-props-source-btn svg {
display: block;
}
/* Tooltip - fixed position, mounted at shadow root level */
.we-tooltip {
position: fixed;
transform: translateX(-50%);
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: #fff;
background: rgba(15, 23, 42, 0.92);
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
z-index: 10000;
}
.we-tooltip[hidden] {
display: none;
}
.we-props-title-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.we-props-title-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
/* Action button - minimal icon style for title bar */
.we-props-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: var(--we-text-secondary);
cursor: pointer;
transition: color 0.12s ease;
}
.we-props-action-btn:hover:not(:disabled) {
color: var(--we-text-primary);
}
.we-props-action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.we-props-action-btn svg {
display: block;
}
.we-props-list {
@@ -3094,6 +3208,45 @@ const SHADOW_HOST_STYLES = /* css */ `
display: none;
}
/* Loading animations */
@keyframes we-shimmer {
to {
background-position: 200% center;
}
}
@keyframes we-spin {
to {
transform: rotate(360deg);
}
}
.we-props-empty.we-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.we-props-empty.we-loading svg {
flex-shrink: 0;
color: #94a3b8;
}
.we-props-empty.we-loading span {
background: linear-gradient(
90deg,
#64748b 0%,
#94a3b8 50%,
#64748b 100%
);
background-size: 200% auto;
color: transparent;
-webkit-background-clip: text;
background-clip: text;
animation: we-shimmer 2s linear infinite;
}
.we-props-group {
padding: 0 0 8px 0;
margin-top: 4px;
@@ -0,0 +1,679 @@
/* eslint-disable */
/**
* Element Picker Inject Script
*
* Injected script to let the user manually pick elements for chrome_request_element_selection.
* - Writes refs into window.__claudeElementMap (compatible with accessibility-tree-helper.js)
* - Generates stable CSS selectors (prefers id/data-testid/etc.)
* - Supports iframe picking by reporting selection via chrome.runtime.sendMessage (background reads sender.frameId)
*/
(function () {
'use strict';
// Prevent double initialization
if (window.__MCP_ELEMENT_PICKER_INITIALIZED__) return;
window.__MCP_ELEMENT_PICKER_INITIALIZED__ = true;
// ============================================================
// Constants
// ============================================================
const UI_HOST_ID = '__mcp_element_picker_host__';
const HIGHLIGHT_ID = '__mcp_element_picker_highlight__';
const MAX_TEXT_LEN = 160;
// Highlight colors matching quick-panel accent
const HIGHLIGHT_COLOR = 'rgba(192, 132, 252, 0.95)';
const HIGHLIGHT_BG = 'rgba(192, 132, 252, 0.06)';
const HIGHLIGHT_SHADOW = 'rgba(192, 132, 252, 0.20)';
// ============================================================
// State
// ============================================================
const STATE = {
active: false,
sessionId: null,
activeRequestId: null,
listenersAttached: false,
hoverRafId: null,
pendingHoverEvent: null,
lastHoverEl: null,
highlighter: null,
};
// ============================================================
// CSS Escape Helper
// ============================================================
function cssEscape(value) {
try {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
} catch {
// Fallback
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
}
// ============================================================
// UI Detection Helpers
// ============================================================
function getUiHost() {
try {
return document.getElementById(UI_HOST_ID);
} catch {
return null;
}
}
function isOverlayElement(node) {
if (!(node instanceof Node)) return false;
const host = getUiHost();
if (!host) return false;
if (node === host) return true;
const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;
return root instanceof ShadowRoot && root.host === host;
}
function isEventFromUi(ev) {
if (!ev) return false;
try {
if (typeof ev.composedPath === 'function') {
const path = ev.composedPath();
if (Array.isArray(path)) {
return path.some((n) => isOverlayElement(n));
}
}
} catch {
// Fallback
}
return isOverlayElement(ev.target);
}
/**
* Get the deepest page target from an event, handling Shadow DOM.
*/
function getDeepPageTarget(ev) {
if (!ev) return null;
try {
const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;
if (Array.isArray(path) && path.length > 0) {
for (const node of path) {
if (node instanceof Element && !isOverlayElement(node)) {
return node;
}
}
}
} catch {
// Fallback
}
const fallback = ev.target instanceof Element ? ev.target : null;
if (fallback && !isOverlayElement(fallback)) {
return fallback;
}
return null;
}
// ============================================================
// Highlighter
// ============================================================
function ensureHighlighter() {
if (STATE.highlighter && STATE.highlighter.isConnected) {
return STATE.highlighter;
}
// Remove any existing highlighter
try {
const existing = document.getElementById(HIGHLIGHT_ID);
if (existing) existing.remove();
} catch {
// Best effort
}
const hl = document.createElement('div');
hl.id = HIGHLIGHT_ID;
Object.assign(hl.style, {
position: 'fixed',
left: '0px',
top: '0px',
width: '0px',
height: '0px',
border: `2px solid ${HIGHLIGHT_COLOR}`,
borderRadius: '6px',
boxShadow: `0 0 0 2px ${HIGHLIGHT_SHADOW}`,
background: HIGHLIGHT_BG,
pointerEvents: 'none',
zIndex: '2147483647',
display: 'none',
transition: 'transform 60ms linear, width 60ms linear, height 60ms linear',
});
try {
(document.documentElement || document.body).appendChild(hl);
} catch {
// Best effort
}
STATE.highlighter = hl;
return hl;
}
function clearHighlighter() {
const hl = STATE.highlighter;
if (!hl) return;
try {
hl.style.display = 'none';
} catch {
// Best effort
}
}
function moveHighlighterTo(el) {
const hl = ensureHighlighter();
if (!hl || !(el instanceof Element)) return;
let rect;
try {
rect = el.getBoundingClientRect();
} catch {
clearHighlighter();
return;
}
if (!rect || rect.width <= 0 || rect.height <= 0) {
clearHighlighter();
return;
}
try {
hl.style.display = 'block';
hl.style.transform = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`;
hl.style.width = `${Math.round(rect.width)}px`;
hl.style.height = `${Math.round(rect.height)}px`;
} catch {
// Best effort
}
}
// ============================================================
// Selector Uniqueness Check (Optimized)
// ============================================================
/**
* Check if element is inside a Shadow DOM.
*/
function isInShadowDom(el) {
try {
const root = el.getRootNode();
return root instanceof ShadowRoot;
} catch {
return false;
}
}
/**
* Fast uniqueness check using native querySelectorAll.
* For Shadow DOM elements, queries within their shadow root only.
*/
function isSelectorUnique(selector, target) {
if (!selector || !(target instanceof Element)) return false;
try {
// For elements not in Shadow DOM, use fast native query
if (!isInShadowDom(target)) {
const matches = document.querySelectorAll(selector);
return matches.length === 1 && matches[0] === target;
}
// For Shadow DOM elements, query within their root
const root = target.getRootNode();
if (root instanceof ShadowRoot) {
const matches = root.querySelectorAll(selector);
return matches.length === 1 && matches[0] === target;
}
return false;
} catch {
return false;
}
}
// ============================================================
// Selector Generation (Stable & Unique)
// ============================================================
function buildPathFromAncestor(ancestor, target) {
const segs = [];
let cur = target;
const root = target.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
const boundary = isShadowElement ? root.host : document.body;
while (cur && cur !== ancestor && cur !== boundary) {
let seg = cur.tagName.toLowerCase();
const parent = cur.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
if (siblings.length > 1) {
seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
}
}
segs.unshift(seg);
cur = parent;
if (isShadowElement && cur === boundary) break;
}
return segs.join(' > ');
}
function buildFullPath(el) {
let path = '';
let current = el;
const root = el.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
const boundary = isShadowElement ? root.host : document.body;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {
let sel = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
if (siblings.length > 1) {
sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;
}
}
path = path ? `${sel} > ${path}` : sel;
current = parent;
if (isShadowElement && current === boundary) break;
}
if (isShadowElement) return path || el.tagName.toLowerCase();
return path ? `body > ${path}` : 'body';
}
/**
* Generate a stable CSS selector for an element.
* Prioritizes: id > data-testid/data-test/etc > anchor + relative path > full path
*/
function generateSelector(el) {
if (!(el instanceof Element)) return '';
// Prefer unique IDs
try {
if (el.id) {
const idSel = `#${cssEscape(el.id)}`;
if (isSelectorUnique(idSel, el)) return idSel;
}
} catch {
// Continue
}
// Prefer stable test attributes
try {
const attrNames = [
'data-testid',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
'aria-label',
'title',
'alt',
];
const tag = el.tagName.toLowerCase();
for (const attr of attrNames) {
const v = el.getAttribute(attr);
if (!v) continue;
const attrSel = `[${attr}="${cssEscape(v)}"]`;
const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;
if (isSelectorUnique(testSel, el)) return testSel;
}
} catch {
// Continue
}
// Anchor + relative path
try {
let cur = el;
const anchorAttrs = [
'id',
'data-testid',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
];
const root = el.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
const boundary = isShadowElement ? root.host : document.body;
while (cur && cur !== boundary) {
if (cur.id) {
const anchor = `#${cssEscape(cur.id)}`;
if (isSelectorUnique(anchor, cur)) {
const rel = buildPathFromAncestor(cur, el);
const composed = rel ? `${anchor} ${rel}` : anchor;
if (isSelectorUnique(composed, el)) return composed;
}
}
for (const attr of anchorAttrs) {
const val = cur.getAttribute(attr);
if (!val) continue;
const aSel = `[${attr}="${cssEscape(val)}"]`;
if (isSelectorUnique(aSel, cur)) {
const rel = buildPathFromAncestor(cur, el);
const composed = rel ? `${aSel} ${rel}` : aSel;
if (isSelectorUnique(composed, el)) return composed;
}
}
cur = cur.parentElement;
}
} catch {
// Continue
}
// Fallback to full path
return buildFullPath(el);
}
// ============================================================
// Text Summarization
// ============================================================
function summarizeText(el) {
if (!(el instanceof Element)) return '';
try {
const aria = el.getAttribute('aria-label');
if (aria && aria.trim()) return aria.trim().slice(0, MAX_TEXT_LEN);
const placeholder = el.getAttribute('placeholder');
if (placeholder && placeholder.trim()) return placeholder.trim().slice(0, MAX_TEXT_LEN);
const title = el.getAttribute('title');
if (title && title.trim()) return title.trim().slice(0, MAX_TEXT_LEN);
const alt = el.getAttribute('alt');
if (alt && alt.trim()) return alt.trim().slice(0, MAX_TEXT_LEN);
} catch {
// Continue
}
try {
const t = (el.textContent || '').trim().replace(/\s+/g, ' ');
return t ? t.slice(0, MAX_TEXT_LEN) : '';
} catch {
return '';
}
}
// ============================================================
// Ref Management (Compatible with accessibility-tree-helper.js)
// ============================================================
function ensureRefForElement(el) {
try {
if (!window.__claudeElementMap) window.__claudeElementMap = {};
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
} catch {
// Best effort
}
// Check if element already has a ref
let refId = null;
try {
for (const k in window.__claudeElementMap) {
const w = window.__claudeElementMap[k];
if (w && w.deref && w.deref() === el) {
refId = k;
break;
}
}
} catch {
// Continue
}
// Create new ref if needed
if (!refId) {
try {
refId = `ref_${++window.__claudeRefCounter}`;
window.__claudeElementMap[refId] = new WeakRef(el);
} catch {
// Continue
}
}
return refId || '';
}
// ============================================================
// Communication
// ============================================================
function sendFrameEvent(payload) {
try {
chrome.runtime.sendMessage(payload);
} catch {
// Best effort
}
}
// ============================================================
// Event Handlers
// ============================================================
function processMouseMove(ev) {
if (!STATE.active) return;
// Skip if event is from our UI
if (isEventFromUi(ev)) {
STATE.lastHoverEl = null;
clearHighlighter();
return;
}
const target = getDeepPageTarget(ev);
if (!target) {
STATE.lastHoverEl = null;
clearHighlighter();
return;
}
// Skip if same element
if (STATE.lastHoverEl === target) return;
STATE.lastHoverEl = target;
moveHighlighterTo(target);
}
function onMouseMove(ev) {
if (!STATE.active) return;
STATE.pendingHoverEvent = ev;
if (STATE.hoverRafId != null) return;
STATE.hoverRafId = requestAnimationFrame(() => {
STATE.hoverRafId = null;
const latest = STATE.pendingHoverEvent;
STATE.pendingHoverEvent = null;
if (!latest) return;
processMouseMove(latest);
});
}
function onClick(ev) {
if (!STATE.active) return;
// Allow UI interactions without interference
if (isEventFromUi(ev)) return;
const rawTarget = ev.target instanceof Element ? ev.target : null;
if (!rawTarget) return;
// Require an active request id so background can map the selection
if (!STATE.sessionId || !STATE.activeRequestId) return;
ev.preventDefault();
ev.stopPropagation();
const target = getDeepPageTarget(ev) || rawTarget;
if (!(target instanceof Element)) return;
const ref = ensureRefForElement(target);
const selector = generateSelector(target);
let rect;
try {
rect = target.getBoundingClientRect();
} catch {
rect = { x: 0, y: 0, width: 0, height: 0, left: 0, top: 0 };
}
const center = {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
sendFrameEvent({
type: 'element_picker_frame_event',
sessionId: STATE.sessionId,
event: 'selected',
requestId: STATE.activeRequestId,
element: {
ref,
selector,
selectorType: 'css',
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
center,
text: summarizeText(target),
tagName: target.tagName ? String(target.tagName).toLowerCase() : '',
},
});
}
function onKeyDown(ev) {
if (!STATE.active) return;
if (ev && ev.key === 'Escape') {
if (isEventFromUi(ev)) return;
ev.preventDefault();
ev.stopPropagation();
if (STATE.sessionId) {
sendFrameEvent({
type: 'element_picker_frame_event',
sessionId: STATE.sessionId,
event: 'cancel',
});
}
}
}
// ============================================================
// Listener Management
// ============================================================
function attachListeners() {
if (STATE.listenersAttached) return;
window.addEventListener('mousemove', onMouseMove, true);
window.addEventListener('click', onClick, true);
window.addEventListener('keydown', onKeyDown, true);
STATE.listenersAttached = true;
}
function detachListeners() {
if (!STATE.listenersAttached) return;
window.removeEventListener('mousemove', onMouseMove, true);
window.removeEventListener('click', onClick, true);
window.removeEventListener('keydown', onKeyDown, true);
STATE.listenersAttached = false;
}
// ============================================================
// Session Management API
// ============================================================
function startSession(payload) {
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
if (!sessionId) return;
STATE.active = true;
STATE.sessionId = sessionId;
STATE.activeRequestId =
payload && payload.activeRequestId ? String(payload.activeRequestId) : null;
ensureHighlighter();
attachListeners();
}
function stopSession(payload) {
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
// Only stop if session matches or no specific session requested
if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;
STATE.active = false;
STATE.sessionId = null;
STATE.activeRequestId = null;
STATE.lastHoverEl = null;
detachListeners();
clearHighlighter();
// Remove highlighter element
try {
const hl = STATE.highlighter;
if (hl && hl.remove) hl.remove();
} catch {
// Best effort
}
STATE.highlighter = null;
}
function setActiveRequest(payload) {
const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';
if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;
STATE.activeRequestId =
payload && payload.activeRequestId ? String(payload.activeRequestId) : null;
}
// ============================================================
// Expose API for Background Script
// ============================================================
window.__mcpElementPicker = {
startSession,
stopSession,
setActiveRequest,
};
// ============================================================
// Message Listener (for direct communication)
// ============================================================
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (request && request.action === 'chrome_request_element_selection_ping') {
sendResponse({ status: 'pong' });
return false;
}
if (request && request.action === 'elementPickerStart') {
startSession(request);
sendResponse({ success: true });
return false;
}
if (request && request.action === 'elementPickerStop') {
stopSession(request);
sendResponse({ success: true });
return false;
}
if (request && request.action === 'elementPickerSetActiveRequest') {
setActiveRequest(request);
sendResponse({ success: true });
return false;
}
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return false;
}
return false;
});
})();
@@ -393,6 +393,43 @@
};
},
/**
* Get React version from renderer or global.
* Prioritizes specific renderer version for multi-renderer scenarios.
*
* @param {object} hookInfo - Result from detectStatus()
* @param {object} [specificRenderer] - Specific renderer to prefer (from resolveFiberWithRenderer)
* @returns {string | undefined}
*/
getVersion(hookInfo, specificRenderer) {
try {
// Priority 1: Specific renderer version (for multi-renderer scenarios)
if (specificRenderer) {
const version = specificRenderer.version;
if (typeof version === 'string' && version.trim()) {
return version.trim();
}
}
// Priority 2: Any renderer with version
const renderers = hookInfo?.renderers || [];
for (const item of renderers) {
const version = item?.renderer?.version;
if (typeof version === 'string' && version.trim()) {
return version.trim();
}
}
// Priority 3: Global React object (if exposed)
if (typeof window !== 'undefined' && window.React?.version) {
return String(window.React.version).trim();
}
} catch {
// Best-effort
}
return undefined;
},
/**
* Find React fiber from DOM node
*/
@@ -450,6 +487,66 @@
}
},
/**
* Extract debug source from React Fiber.
* Walks up the fiber tree checking _debugSource and _debugOwner._debugSource.
*
* @param {object} fiber - React Fiber node
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
*/
getDebugSource(fiber) {
try {
let current = fiber;
for (let i = 0; i < 40 && current; i++) {
if (!isObject(current)) break;
// Try direct _debugSource first
const src = isObject(current._debugSource) ? current._debugSource : null;
if (src) {
const file = safeString(src.fileName).trim();
if (file) {
return this.buildDebugSourceResult(file, src.lineNumber, src.columnNumber, current);
}
}
// Fallback to _debugOwner._debugSource
const owner = isObject(current._debugOwner) ? current._debugOwner : null;
const ownerSrc = owner && isObject(owner._debugSource) ? owner._debugSource : null;
if (ownerSrc) {
const ownerFile = safeString(ownerSrc.fileName).trim();
if (ownerFile) {
return this.buildDebugSourceResult(
ownerFile,
ownerSrc.lineNumber,
ownerSrc.columnNumber,
owner,
);
}
}
current = current.return;
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Build debug source result with validated line/column values.
* @private
*/
buildDebugSourceResult(file, lineNumber, columnNumber, fiberForName) {
const line = Number(lineNumber);
const column = Number(columnNumber);
return {
file,
line: Number.isFinite(line) && line > 0 ? line : undefined,
column: Number.isFinite(column) && column > 0 ? column : undefined,
componentName: this.getComponentName(fiberForName),
};
},
/**
* Resolve fiber using renderer.findFiberByHostInstance when available
*/
@@ -571,6 +668,125 @@
}
},
/**
* Parse Vue inspector location attribute value.
* Format: "src/components/Foo.vue:23:7" or "C:\path\file.vue:10:5" (Windows)
*
* Uses trailing regex to safely handle Windows paths with drive letters.
*
* @param {string} value - The data-v-inspector attribute value
* @returns {{ file: string, line?: number, column?: number } | null}
*/
parseVInspector(value) {
if (typeof value !== 'string') return null;
const raw = value.trim();
if (!raw) return null;
// Match only trailing :line or :line:column to avoid Windows drive letter issues
const match = raw.match(/:([\d]+)(?::([\d]+))?$/);
if (!match) {
// No line info, return file only
return { file: raw };
}
const file = raw.slice(0, match.index).trim();
if (!file) return null;
const line = Number.parseInt(match[1], 10);
const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
return {
file,
line: Number.isFinite(line) && line > 0 ? line : undefined,
column: Number.isFinite(column) && column > 0 ? column : undefined,
};
},
/**
* Walk up DOM tree to find data-v-inspector attribute.
* This attribute is injected by @vitejs/plugin-vue-inspector.
*
* @param {Element} element - Starting DOM element
* @param {number} [maxDepth=15] - Maximum depth to traverse
* @returns {{ file: string, line?: number, column?: number } | null}
*/
findInspectorLocation(element, maxDepth = 15) {
try {
let node = element;
for (let depth = 0; depth < maxDepth && node; depth++) {
if (typeof node.getAttribute === 'function') {
const attr = node.getAttribute('data-v-inspector');
if (attr) {
const parsed = this.parseVInspector(attr);
if (parsed?.file) return parsed;
}
}
node = node.parentElement;
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Get Vue component debug source.
* Priority: data-v-inspector (has line/column) > type.__file (file only)
*
* @param {object} instance - Vue component instance
* @param {Element} targetElement - DOM element for inspector lookup
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
*/
getDebugSource(instance, targetElement) {
try {
// Priority 1: data-v-inspector attribute (has precise line/column)
const inspector = this.findInspectorLocation(targetElement);
if (inspector?.file) {
return {
file: inspector.file,
line: inspector.line,
column: inspector.column,
componentName: this.getComponentName(instance),
};
}
// Priority 2: type.__file (file only, no line/column)
const typeFile = instance?.type?.__file;
if (typeof typeFile === 'string') {
const file = typeFile.trim();
if (file) {
return {
file,
componentName: this.getComponentName(instance),
};
}
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Get Vue 3 version from instance.
* Note: This adapter only supports Vue 3 (via __vueParentComponent).
*
* @param {object} instance - Vue 3 component instance
* @returns {string | undefined}
*/
getVersion(instance) {
try {
// Vue 3: Get version from app context
const appVersion = instance?.appContext?.app?.version;
if (typeof appVersion === 'string' && appVersion.trim()) {
return appVersion.trim();
}
} catch {
// Best-effort
}
return undefined;
},
/**
* Get writable props container (vnode.props or instance.props)
* @deprecated Use getWriteContainers for better targeting
@@ -1385,7 +1601,9 @@
if (init?.hookStatus) data.hookStatus = init.hookStatus;
if (typeof init?.needsRefresh === 'boolean') data.needsRefresh = init.needsRefresh;
if (init?.framework) data.framework = init.framework;
if (init?.frameworkVersion) data.frameworkVersion = init.frameworkVersion;
if (init?.componentName) data.componentName = init.componentName;
if (init?.debugSource) data.debugSource = init.debugSource;
if (init?.props) data.props = init.props;
if (init?.capabilities) data.capabilities = init.capabilities;
if (init?.meta) data.meta = init.meta;
@@ -1428,10 +1646,13 @@
const fw = target ? FrameworkDetector.detect(target) : { framework: 'unknown', data: null };
let componentName;
let debugSource;
let canRead = false;
let canWrite = false;
let needsRefresh = false;
let frameworkVersion;
if (fw.framework === 'react') {
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
const componentFiber = fiberInfo.fiber
@@ -1439,12 +1660,19 @@
: null;
componentName = componentFiber ? ReactAdapter.getComponentName(componentFiber) : undefined;
// Extract debug source from component fiber or raw fiber
const sourceFiber = componentFiber || fiberInfo.fiber;
debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
// Pass specific renderer to prioritize its version in multi-renderer scenarios
frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
canRead = Boolean(componentFiber);
canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);
needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;
} else if (fw.framework === 'vue') {
const instance = fw.data;
componentName = VueAdapter.getComponentName(instance);
debugSource = instance ? VueAdapter.getDebugSource(instance, target) : undefined;
frameworkVersion = VueAdapter.getVersion(instance);
canRead = Boolean(instance);
canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);
needsRefresh = false;
@@ -1453,7 +1681,9 @@
const data = buildResponseData({
hookStatus,
framework: fw.framework,
frameworkVersion,
componentName,
debugSource,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh,
});
@@ -1494,10 +1724,18 @@
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
: null;
// Extract debug source even if component fiber not found
const sourceFiber = componentFiber || fiberInfo.fiber;
const debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
// Pass specific renderer to prioritize its version in multi-renderer scenarios
const frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
if (!componentFiber) {
const data = buildResponseData({
hookStatus,
framework: 'react',
frameworkVersion,
debugSource,
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
needsRefresh: false,
});
@@ -1519,7 +1757,9 @@
const data = buildResponseData({
hookStatus,
framework: 'react',
frameworkVersion,
componentName,
debugSource,
props: serialized,
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
needsRefresh,
@@ -1530,10 +1770,13 @@
if (fw.framework === 'vue') {
const instance = fw.data;
const frameworkVersion = VueAdapter.getVersion(instance);
if (!instance) {
const data = buildResponseData({
hookStatus,
framework: 'vue',
frameworkVersion,
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
needsRefresh: false,
});
@@ -1546,6 +1789,7 @@
}
const componentName = VueAdapter.getComponentName(instance);
const debugSource = VueAdapter.getDebugSource(instance, target);
// Read both props and attrs
let rootProps = null;
@@ -1592,7 +1836,9 @@
const data = buildResponseData({
hookStatus,
framework: 'vue',
frameworkVersion,
componentName,
debugSource,
props: serialized,
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
needsRefresh: false,
@@ -0,0 +1,797 @@
/**
* Element Picker Controller
*
* Creates and manages the Element Picker Panel UI, which displays:
* - List of element requests from the AI
* - Current selection status for each request
* - Countdown timer
* - Cancel/Confirm actions
*/
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
import {
mountQuickPanelShadowHost,
type QuickPanelShadowHostElements,
type QuickPanelShadowHostManager,
} from '@/shared/quick-panel/ui';
import type { PickedElement } from 'chrome-mcp-shared';
// ============================================================
// Types
// ============================================================
export interface ElementPickerControllerOptions {
/** Custom host element ID */
hostId?: string;
/** Custom z-index */
zIndex?: number;
/** Called when user clicks Cancel */
onCancel?: () => void;
/** Called when user clicks Confirm */
onConfirm?: () => void;
/** Called when user switches to a different request */
onSetActiveRequest?: (requestId: string) => void;
/** Called when user clears a selection */
onClearSelection?: (requestId: string) => void;
}
export interface ElementPickerController {
/** Show the panel with initial state */
show: (state: ElementPickerUiState) => void;
/** Update the panel state */
update: (patch: ElementPickerUiPatch) => void;
/** Hide and clean up the panel */
hide: () => void;
/** Check if the panel is currently visible */
isVisible: () => boolean;
/** Dispose and clean up all resources */
dispose: () => void;
}
export interface ElementPickerUiRequest {
id: string;
name: string;
description?: string;
}
export interface ElementPickerUiState {
sessionId: string;
requests: ElementPickerUiRequest[];
activeRequestId: string | null;
selections: Record<string, PickedElement | null>;
deadlineTs: number;
errorMessage: string | null;
}
export type ElementPickerUiPatch = Partial<Omit<ElementPickerUiState, 'sessionId'>> & {
sessionId: string;
};
// ============================================================
// Constants
// ============================================================
const DEFAULT_HOST_ID = '__mcp_element_picker_host__';
const DEFAULT_Z_INDEX = 2147483647;
// ============================================================
// Styles (Quick Panel compatible)
// ============================================================
const ELEMENT_PICKER_STYLES = /* css */ `
/* Overlay positioning - bottom-right corner */
.ep-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 16px;
pointer-events: none;
}
/* Panel sizing */
.ep-panel {
width: min(480px, calc(100vw - 32px));
max-height: min(600px, calc(100vh - 32px));
pointer-events: auto;
}
/* Countdown badge */
.ep-countdown {
font-family: var(--ac-font-code);
font-size: 12px;
color: var(--ac-text-muted);
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--qp-glass-divider);
background: color-mix(in srgb, var(--qp-glass-input-bg) 80%, transparent);
user-select: none;
white-space: nowrap;
}
.ep-countdown--warning {
color: var(--ac-warning);
border-color: color-mix(in srgb, var(--ac-warning) 40%, var(--qp-glass-divider));
}
.ep-countdown--danger {
color: var(--ac-danger);
border-color: color-mix(in srgb, var(--ac-danger) 40%, var(--qp-glass-divider));
animation: ep-pulse 1s ease-in-out infinite;
}
@keyframes ep-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Hint text */
.ep-hint {
margin: 0 0 10px 0;
font-size: 12px;
color: var(--ac-text-muted);
}
/* Error banner */
.ep-error {
margin: 0 0 10px 0;
padding: 8px 10px;
border-radius: var(--ac-radius-card);
border: 1px solid color-mix(in srgb, var(--ac-danger) 55%, var(--ac-border));
background: color-mix(in srgb, var(--ac-danger) 10%, transparent);
color: color-mix(in srgb, var(--ac-danger) 85%, var(--ac-text));
font-size: 12px;
}
/* Request list */
.ep-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Request item card */
.ep-item {
border-radius: var(--ac-radius-card);
border: var(--ac-border-width) solid var(--ac-border);
box-shadow: var(--ac-shadow-card);
background: var(--ac-surface);
padding: 10px 12px;
transition: border-color var(--ac-motion-fast), box-shadow var(--ac-motion-fast);
}
.ep-item--active {
border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--ac-border));
box-shadow:
0 0 0 2px color-mix(in srgb, var(--ac-accent-subtle) 65%, transparent),
var(--ac-shadow-card);
}
/* Item header */
.ep-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ep-item-title {
min-width: 0;
font-weight: 600;
font-size: 13px;
color: var(--ac-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Status badge */
.ep-badge {
flex: none;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--qp-glass-divider);
color: var(--ac-text-muted);
background: color-mix(in srgb, var(--ac-surface-muted) 65%, transparent);
user-select: none;
}
.ep-badge--selected {
border-color: color-mix(in srgb, var(--ac-success) 55%, var(--qp-glass-divider));
color: color-mix(in srgb, var(--ac-success) 85%, var(--ac-text));
background: color-mix(in srgb, var(--ac-success) 10%, transparent);
}
.ep-badge--picking {
border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--qp-glass-divider));
color: var(--ac-accent);
background: color-mix(in srgb, var(--ac-accent) 10%, transparent);
animation: ep-pulse 1.5s ease-in-out infinite;
}
/* Description text */
.ep-desc {
margin-top: 6px;
font-size: 12px;
color: var(--ac-text-muted);
white-space: pre-wrap;
}
/* Picked element info */
.ep-picked {
margin-top: 8px;
font-size: 12px;
color: var(--ac-text);
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: var(--ac-radius-inner);
background: var(--ac-surface-muted);
}
.ep-picked-text {
font-weight: 500;
word-break: break-word;
}
.ep-picked-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
}
.ep-picked code {
font-family: var(--ac-font-code);
font-size: 10px;
color: var(--ac-text-muted);
padding: 2px 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.05);
word-break: break-all;
}
/* Action buttons row */
.ep-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
/* Footer */
.ep-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ep-footer-left {
font-size: 11px;
color: var(--ac-text-muted);
}
.ep-footer-right {
display: flex;
gap: 8px;
}
`;
// ============================================================
// Utility Functions
// ============================================================
function formatCountdown(deadlineTs: number): {
text: string;
level: 'normal' | 'warning' | 'danger';
} {
const remainingMs = Math.max(0, deadlineTs - Date.now());
const totalSeconds = Math.floor(remainingMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const text = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
// Warning at 1 minute, danger at 30 seconds
let level: 'normal' | 'warning' | 'danger' = 'normal';
if (totalSeconds <= 30) {
level = 'danger';
} else if (totalSeconds <= 60) {
level = 'warning';
}
return { text, level };
}
function truncate(text: string, max = 80): string {
const t = String(text || '')
.trim()
.replace(/\s+/g, ' ');
if (t.length <= max) return t;
return `${t.slice(0, Math.max(0, max - 1))}...`;
}
// ============================================================
// Controller Factory
// ============================================================
export function createElementPickerController(
options: ElementPickerControllerOptions = {},
): ElementPickerController {
let disposed = false;
let shadowHost: QuickPanelShadowHostManager | null = null;
let elements: QuickPanelShadowHostElements | null = null;
let disposer: Disposer | null = null;
let state: ElementPickerUiState | null = null;
// DOM refs
let overlayEl: HTMLDivElement | null = null;
let panelEl: HTMLDivElement | null = null;
let countdownEl: HTMLSpanElement | null = null;
let errorEl: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
let confirmBtn: HTMLButtonElement | null = null;
let cancelBtn: HTMLButtonElement | null = null;
let progressEl: HTMLSpanElement | null = null;
let timerId: ReturnType<typeof setInterval> | null = null;
// Cached item elements for incremental updates
interface ItemElements {
container: HTMLDivElement;
badge: HTMLDivElement;
pickedContainer: HTMLDivElement | null;
pickBtn: HTMLButtonElement;
clearBtn: HTMLButtonElement;
}
const itemElementsMap = new Map<string, ItemElements>();
const hostId = options.hostId ?? DEFAULT_HOST_ID;
const zIndex = options.zIndex ?? DEFAULT_Z_INDEX;
function ensureMounted(): void {
if (shadowHost && elements) return;
shadowHost = mountQuickPanelShadowHost({ hostId, zIndex });
elements = shadowHost.getElements();
if (!elements) throw new Error('Failed to mount Element Picker shadow host');
const localDisposer = new Disposer();
disposer = localDisposer;
// Inject local styles
const styleEl = document.createElement('style');
styleEl.textContent = ELEMENT_PICKER_STYLES;
elements.shadowRoot.append(styleEl);
localDisposer.add(() => styleEl.remove());
// Build UI structure
overlayEl = document.createElement('div');
overlayEl.className = 'ep-overlay';
panelEl = document.createElement('div');
panelEl.className = 'qp-panel qp-liquid-shimmer ep-panel';
panelEl.setAttribute('role', 'dialog');
panelEl.setAttribute('aria-modal', 'false');
panelEl.setAttribute('aria-label', 'Element Picker');
// Header
const headerEl = document.createElement('div');
headerEl.className = 'qp-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'qp-header-left';
const brand = document.createElement('div');
brand.className = 'qp-brand';
brand.textContent = '\u{1F446}'; // Pointing up emoji
const title = document.createElement('div');
title.className = 'qp-title';
const titleName = document.createElement('div');
titleName.className = 'qp-title-name';
titleName.textContent = 'Element Picker';
const titleSub = document.createElement('div');
titleSub.className = 'qp-title-sub';
titleSub.textContent = 'Click on the requested elements';
title.append(titleName, titleSub);
headerLeft.append(brand, title);
const headerRight = document.createElement('div');
headerRight.className = 'qp-header-right';
countdownEl = document.createElement('span');
countdownEl.className = 'ep-countdown';
countdownEl.textContent = '03:00';
headerRight.append(countdownEl);
headerEl.append(headerLeft, headerRight);
// Content
const contentEl = document.createElement('div');
contentEl.className = 'qp-content ac-scroll';
const hintEl = document.createElement('div');
hintEl.className = 'ep-hint';
hintEl.textContent = 'Click on each element the AI needs. Press Esc to cancel.';
errorEl = document.createElement('div');
errorEl.className = 'ep-error';
errorEl.hidden = true;
listEl = document.createElement('div');
listEl.className = 'ep-list';
contentEl.append(hintEl, errorEl, listEl);
// Footer
const footerEl = document.createElement('div');
footerEl.className = 'qp-composer';
const footerInner = document.createElement('div');
footerInner.className = 'ep-footer';
const footerLeft = document.createElement('div');
footerLeft.className = 'ep-footer-left';
progressEl = document.createElement('span');
progressEl.textContent = '0/0 selected';
footerLeft.append(progressEl);
const footerRight = document.createElement('div');
footerRight.className = 'ep-footer-right';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'qp-btn ac-btn ac-focus-ring';
cancelBtn.textContent = 'Cancel';
confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className = 'qp-btn ac-btn ac-focus-ring qp-btn--primary';
confirmBtn.textContent = 'Confirm';
footerRight.append(cancelBtn, confirmBtn);
footerInner.append(footerLeft, footerRight);
footerEl.append(footerInner);
panelEl.append(headerEl, contentEl, footerEl);
overlayEl.append(panelEl);
elements.root.append(overlayEl);
localDisposer.add(() => overlayEl?.remove());
// Event listeners
localDisposer.listen(cancelBtn, 'click', () => options.onCancel?.());
localDisposer.listen(confirmBtn, 'click', () => options.onConfirm?.());
// Esc key to cancel - use capture phase on shadowRoot to intercept before Quick Panel stops propagation
const handleEscKey = (e: Event) => {
if (e instanceof KeyboardEvent && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
options.onCancel?.();
}
};
elements.shadowRoot.addEventListener('keydown', handleEscKey, { capture: true });
localDisposer.add(() =>
elements?.shadowRoot.removeEventListener('keydown', handleEscKey, { capture: true }),
);
}
function clearTimer(): void {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
/**
* Render only the countdown timer (called frequently by interval).
*/
function renderCountdown(): void {
if (!state || !countdownEl) return;
const countdown = formatCountdown(state.deadlineTs);
countdownEl.textContent = countdown.text;
countdownEl.className = `ep-countdown${countdown.level !== 'normal' ? ` ep-countdown--${countdown.level}` : ''}`;
}
/**
* Create a picked element info container.
*/
function createPickedInfoEl(picked: PickedElement): HTMLDivElement {
const pickedEl = document.createElement('div');
pickedEl.className = 'ep-picked';
if (picked.text) {
const textEl = document.createElement('div');
textEl.className = 'ep-picked-text';
textEl.textContent = `"${truncate(picked.text, 80)}"`;
pickedEl.append(textEl);
}
const metaEl = document.createElement('div');
metaEl.className = 'ep-picked-meta';
const tagCode = document.createElement('code');
tagCode.textContent = picked.tagName || 'element';
metaEl.append(tagCode);
const refCode = document.createElement('code');
refCode.textContent = `ref=${picked.ref}`;
metaEl.append(refCode);
if (picked.frameId > 0) {
const frameCode = document.createElement('code');
frameCode.textContent = `frame=${picked.frameId}`;
metaEl.append(frameCode);
}
pickedEl.append(metaEl);
const selectorEl = document.createElement('div');
const selectorCode = document.createElement('code');
selectorCode.textContent = truncate(picked.selector || '', 100);
selectorEl.append(selectorCode);
pickedEl.append(selectorEl);
return pickedEl;
}
/**
* Create a single request item element.
*/
function createItemEl(req: ElementPickerUiRequest): ItemElements {
const item = document.createElement('div');
item.className = 'ep-item';
item.dataset.requestId = req.id;
// Header row
const header = document.createElement('div');
header.className = 'ep-item-header';
const titleEl = document.createElement('div');
titleEl.className = 'ep-item-title';
titleEl.textContent = req.name;
const badge = document.createElement('div');
badge.className = 'ep-badge';
badge.textContent = 'Pending';
header.append(titleEl, badge);
item.append(header);
// Description (static, only added once)
if (req.description) {
const desc = document.createElement('div');
desc.className = 'ep-desc';
desc.textContent = req.description;
item.append(desc);
}
// Action buttons
const actions = document.createElement('div');
actions.className = 'ep-actions';
const pickBtn = document.createElement('button');
pickBtn.type = 'button';
pickBtn.className = 'qp-btn ac-btn ac-focus-ring';
pickBtn.textContent = 'Pick';
pickBtn.addEventListener('click', () => options.onSetActiveRequest?.(req.id));
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'qp-btn ac-btn ac-focus-ring';
clearBtn.textContent = 'Clear';
clearBtn.disabled = true;
clearBtn.addEventListener('click', () => options.onClearSelection?.(req.id));
actions.append(pickBtn, clearBtn);
item.append(actions);
return { container: item, badge, pickedContainer: null, pickBtn, clearBtn };
}
/**
* Update a single item's display state.
*/
function updateItemEl(
itemEls: ItemElements,
req: ElementPickerUiRequest,
picked: PickedElement | null,
isActive: boolean,
): void {
const { container, badge, pickBtn, clearBtn } = itemEls;
// Update active state
container.classList.toggle('ep-item--active', isActive);
// Update badge
if (picked) {
badge.className = 'ep-badge ep-badge--selected';
badge.textContent = 'Selected';
} else if (isActive) {
badge.className = 'ep-badge ep-badge--picking';
badge.textContent = 'Picking...';
} else {
badge.className = 'ep-badge';
badge.textContent = 'Pending';
}
// Update pick button
pickBtn.textContent = isActive ? 'Picking...' : 'Pick';
pickBtn.disabled = isActive;
// Update clear button
clearBtn.disabled = !picked;
// Handle picked info container
const actionsEl = container.querySelector('.ep-actions');
if (picked) {
if (!itemEls.pickedContainer) {
// Create and insert picked info before actions
const pickedEl = createPickedInfoEl(picked);
actionsEl?.parentNode?.insertBefore(pickedEl, actionsEl);
itemEls.pickedContainer = pickedEl;
} else {
// Update existing picked info
const newPickedEl = createPickedInfoEl(picked);
itemEls.pickedContainer.replaceWith(newPickedEl);
itemEls.pickedContainer = newPickedEl;
}
} else if (itemEls.pickedContainer) {
// Remove picked info
itemEls.pickedContainer.remove();
itemEls.pickedContainer = null;
}
}
/**
* Build the list initially or rebuild if requests changed.
*/
function buildList(): void {
if (!state || !listEl) return;
// Clear existing items and cache
listEl.innerHTML = '';
itemElementsMap.clear();
for (const req of state.requests) {
const itemEls = createItemEl(req);
itemElementsMap.set(req.id, itemEls);
listEl.append(itemEls.container);
}
}
/**
* Full render - updates all dynamic parts.
*/
function render(): void {
if (!state || !listEl || !countdownEl || !confirmBtn || !errorEl || !progressEl) return;
// Countdown (always update)
renderCountdown();
// Error banner
const err = state.errorMessage ? state.errorMessage.trim() : '';
if (err) {
errorEl.hidden = false;
errorEl.textContent = err;
} else {
errorEl.hidden = true;
errorEl.textContent = '';
}
// Rebuild list if requests changed (rare case)
const needsRebuild =
itemElementsMap.size !== state.requests.length ||
state.requests.some((r) => !itemElementsMap.has(r.id));
if (needsRebuild) {
buildList();
}
// Count selected and update items
let selectedCount = 0;
for (const req of state.requests) {
const picked = state.selections[req.id] || null;
const isActive = state.activeRequestId === req.id;
if (picked) selectedCount++;
const itemEls = itemElementsMap.get(req.id);
if (itemEls) {
updateItemEl(itemEls, req, picked, isActive);
}
}
// Progress text
progressEl.textContent = `${selectedCount}/${state.requests.length} selected`;
// Confirm button state
const allSelected = selectedCount === state.requests.length;
confirmBtn.disabled = !allSelected;
confirmBtn.textContent = allSelected
? 'Confirm'
: `Confirm (${selectedCount}/${state.requests.length})`;
}
function show(next: ElementPickerUiState): void {
if (disposed) return;
ensureMounted();
state = next;
render();
clearTimer();
// Timer only updates countdown, not the full list
timerId = setInterval(() => {
if (disposed || !state) return;
renderCountdown();
}, 250);
}
function update(patch: ElementPickerUiPatch): void {
if (disposed) return;
if (!state || state.sessionId !== patch.sessionId) {
// If we don't have matching state yet, ignore update
return;
}
state = {
...state,
...patch,
sessionId: state.sessionId, // Keep stable
requests: patch.requests ?? state.requests,
activeRequestId: patch.activeRequestId ?? state.activeRequestId,
selections: patch.selections ?? state.selections,
deadlineTs: patch.deadlineTs ?? state.deadlineTs,
errorMessage: patch.errorMessage ?? state.errorMessage,
};
render();
}
function hide(): void {
clearTimer();
state = null;
itemElementsMap.clear();
try {
disposer?.dispose();
} finally {
disposer = null;
}
overlayEl = null;
panelEl = null;
countdownEl = null;
errorEl = null;
listEl = null;
confirmBtn = null;
cancelBtn = null;
progressEl = null;
try {
shadowHost?.dispose();
} finally {
shadowHost = null;
elements = null;
}
}
function dispose(): void {
if (disposed) return;
disposed = true;
hide();
}
return {
show,
update,
hide,
isVisible: () => !!shadowHost && !!elements,
dispose,
};
}
@@ -0,0 +1,14 @@
/**
* Element Picker (UI)
*
* A Quick Panel-styled floating panel used by chrome_request_element_selection.
*/
export { createElementPickerController } from './controller';
export type {
ElementPickerController,
ElementPickerControllerOptions,
ElementPickerUiState,
ElementPickerUiRequest,
ElementPickerUiPatch,
} from './controller';
@@ -53,6 +53,8 @@ export {
type QuickPanelMessageRendererOptions,
} from './message-renderer';
export { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';
export {
mountQuickPanelAiChatPanel,
type QuickPanelAiChatPanelManager,
@@ -0,0 +1,60 @@
/**
* Quick Panel Markdown Renderer
*
* Simple markdown renderer for Quick Panel.
* Currently uses plain text rendering - markdown support to be added later
* when proper Vue/content-script integration is resolved.
*/
// ============================================================
// Types
// ============================================================
export interface MarkdownRendererInstance {
/** Update the markdown content */
setContent: (content: string, isStreaming?: boolean) => void;
/** Get current content */
getContent: () => string;
/** Dispose resources */
dispose: () => void;
}
// ============================================================
// Main Factory
// ============================================================
/**
* Create a markdown renderer instance that mounts to a container element.
* Currently renders as plain text - markdown support pending.
*
* @param container - The DOM element to render content into
* @returns Markdown renderer instance with setContent and dispose methods
*/
export function createMarkdownRenderer(container: HTMLElement): MarkdownRendererInstance {
let currentContent = '';
// Create a wrapper div for content
const contentEl = document.createElement('div');
contentEl.className = 'qp-markdown-content';
container.appendChild(contentEl);
return {
setContent(newContent: string, _streaming = false) {
currentContent = newContent;
// For now, render as plain text with basic whitespace preservation
contentEl.textContent = newContent;
},
getContent() {
return currentContent;
},
dispose() {
try {
contentEl.remove();
} catch {
// Best-effort cleanup
}
},
};
}
@@ -3,16 +3,15 @@
*
* Renders AgentChat-compatible messages for the Quick Panel AI Chat UI.
* Features:
* - XSS-safe rendering (textContent only, no innerHTML)
* - Markdown rendering for assistant messages via markstream-vue
* - XSS-safe rendering for user messages (textContent only)
* - Streaming message support (in-place updates via message id)
* - Auto-scroll with proximity detection
* - Memory-efficient DOM recycling
*
* Note: This renderer is framework-agnostic and directly manipulates DOM
* for optimal performance in content script context.
*/
import type { AgentMessage, AgentRole } from 'chrome-mcp-shared';
import { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';
// ============================================================
// Types
@@ -61,6 +60,8 @@ interface MessageEntry {
timeEl: HTMLSpanElement;
metaRightEl: HTMLSpanElement;
requestIdEl: HTMLElement;
/** Markdown renderer for assistant messages */
markdownRenderer: MarkdownRendererInstance | null;
}
// ============================================================
@@ -187,6 +188,12 @@ function createMessageEntry(messageId: string, message: AgentMessage): MessageEn
bubble.append(textEl, metaEl);
wrapper.append(bubble);
// Create markdown renderer for assistant messages
let markdownRenderer: MarkdownRendererInstance | null = null;
if (message.role === 'assistant') {
markdownRenderer = createMarkdownRenderer(textEl);
}
return {
wrapper,
bubble,
@@ -197,6 +204,7 @@ function createMessageEntry(messageId: string, message: AgentMessage): MessageEn
timeEl: metaLeft.time,
metaRightEl: metaRight.container,
requestIdEl: metaRight.requestId,
markdownRenderer,
};
}
@@ -221,10 +229,17 @@ function updateMessageEntry(entry: MessageEntry, messageId: string, message: Age
entry.bubble.className = bubbleClass;
}
// Update text content (XSS-safe)
// Update content based on message role
const textContent = message.content ?? '';
if (entry.textEl.textContent !== textContent) {
entry.textEl.textContent = textContent;
if (message.role === 'assistant' && entry.markdownRenderer) {
// Use markdown renderer for assistant messages
entry.markdownRenderer.setContent(textContent, isStreamingMessage(message));
} else {
// Use plain text for user messages (XSS-safe)
if (entry.textEl.textContent !== textContent) {
entry.textEl.textContent = textContent;
}
}
// Update time display
@@ -343,6 +358,11 @@ export function createQuickPanelMessageRenderer(
entries.delete(id);
// Dispose markdown renderer if exists
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
try {
entry.wrapper.remove();
} catch {
@@ -354,6 +374,13 @@ export function createQuickPanelMessageRenderer(
function clear(): void {
if (disposed) return;
// Dispose all markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
entries.clear();
container.textContent = '';
}
@@ -361,6 +388,13 @@ export function createQuickPanelMessageRenderer(
function setMessages(messages: AgentMessage[]): void {
if (disposed) return;
// Dispose all existing markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
// Clear existing state
entries.clear();
container.textContent = '';
@@ -388,6 +422,13 @@ export function createQuickPanelMessageRenderer(
if (disposed) return;
disposed = true;
// Dispose all markdown renderers
for (const entry of entries.values()) {
if (entry.markdownRenderer) {
entry.markdownRenderer.dispose();
}
}
entries.clear();
container.textContent = '';
}
@@ -76,6 +76,9 @@ const THEME_STORAGE_KEY = 'agentTheme';
/** Default theme if none is set */
const DEFAULT_THEME_ID = 'warm-editorial';
/** Dark theme ID for dark mode */
const DARK_THEME_ID = 'dark-console';
/** Valid theme IDs (subset supported by Quick Panel) */
const VALID_THEME_IDS = new Set([
'warm-editorial',
@@ -86,6 +89,15 @@ const VALID_THEME_IDS = new Set([
'swiss-grid',
]);
/** Light theme IDs that should switch to dark in dark mode */
const LIGHT_THEME_IDS = new Set([
'warm-editorial',
'blueprint-architect',
'zen-journal',
'neo-pop',
'swiss-grid',
]);
/** Events to stop from propagating to the host page */
const BLOCKED_EVENT_TYPES = [
// Pointer events
@@ -142,6 +154,28 @@ function normalizeThemeId(value: unknown): string {
return VALID_THEME_IDS.has(trimmed) ? trimmed : DEFAULT_THEME_ID;
}
/**
* Check if system prefers dark mode.
*/
function systemPrefersDark(): boolean {
try {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
} catch {
return false;
}
}
/**
* Get effective theme ID considering system dark mode preference.
* If system is in dark mode and the theme is a light theme, switch to dark-console.
*/
function getEffectiveThemeId(baseThemeId: string): string {
if (systemPrefersDark() && LIGHT_THEME_IDS.has(baseThemeId)) {
return DARK_THEME_ID;
}
return baseThemeId;
}
/**
* Read the stored theme ID from chrome.storage.
*/
@@ -156,10 +190,12 @@ async function readStoredThemeId(): Promise<string> {
}
/**
* Apply a theme ID to the root element.
* Apply a theme ID to the root element, considering system dark mode preference.
*/
function applyThemeId(root: HTMLElement, themeId: string): void {
root.dataset.agentTheme = normalizeThemeId(themeId);
const normalizedTheme = normalizeThemeId(themeId);
const effectiveTheme = getEffectiveThemeId(normalizedTheme);
root.dataset.agentTheme = effectiveTheme;
}
// ============================================================
@@ -265,6 +301,15 @@ export function mountQuickPanelShadowHost(
applyThemeId(root, themeId);
})();
// System dark mode change listener
// Re-apply theme when system color scheme changes
let currentStoredThemeId = DEFAULT_THEME_ID;
// Track the stored theme ID
void (async () => {
currentStoredThemeId = await readStoredThemeId();
})();
// Theme change listener
const handleStorageChange = (
changes: Record<string, chrome.storage.StorageChange>,
@@ -273,7 +318,9 @@ export function mountQuickPanelShadowHost(
if (areaName !== 'local') return;
const change = changes[THEME_STORAGE_KEY];
if (!change) return;
applyThemeId(root, change.newValue);
// Update tracked theme ID and apply
currentStoredThemeId = normalizeThemeId(change.newValue);
applyThemeId(root, currentStoredThemeId);
};
try {
@@ -283,6 +330,23 @@ export function mountQuickPanelShadowHost(
// Best-effort: theme sync is optional
}
try {
const darkModeMediaQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
if (darkModeMediaQuery) {
const handleDarkModeChange = (): void => {
applyThemeId(root, currentStoredThemeId);
};
// Use addEventListener for modern browsers
if (typeof darkModeMediaQuery.addEventListener === 'function') {
darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
disposer.add(() => darkModeMediaQuery.removeEventListener('change', handleDarkModeChange));
}
}
} catch {
// Best-effort: dark mode detection is optional
}
// Helper to check if a node belongs to this shadow host
const isOverlayElement = (node: unknown): boolean => {
if (!(node instanceof Node)) return false;
@@ -176,14 +176,14 @@ export const QUICK_PANEL_STYLES = /* css */ `
--ac-hover-bg: rgba(255, 255, 255, 0.06);
--ac-hover-bg-subtle: rgba(255, 255, 255, 0.04);
--ac-accent: #c084fc;
--ac-accent-hover: #d8b4fe;
--ac-accent-subtle: rgba(192, 132, 252, 0.14);
--ac-accent-contrast: #0a0c10;
--ac-accent: #d97757;
--ac-accent-hover: #e8956f;
--ac-accent-subtle: rgba(217, 119, 87, 0.18);
--ac-accent-contrast: #ffffff;
--ac-focus-ring: rgba(192, 132, 252, 0.35);
--ac-focus-ring: rgba(217, 119, 87, 0.4);
--ac-timeline-node-pulse-shadow:
0 0 0 2px rgba(192, 132, 252, 0.35), 0 0 14px rgba(192, 132, 252, 0.25);
0 0 0 2px rgba(217, 119, 87, 0.35), 0 0 14px rgba(217, 119, 87, 0.25);
--ac-scrollbar-thumb: rgba(255, 255, 255, 0.12);
--ac-scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);
@@ -869,4 +869,128 @@ export const QUICK_PANEL_STYLES = /* css */ `
align-items: center;
gap: 6px;
}
/* ============================================================
* Markdown Content Styles (for markstream-vue)
* ============================================================ */
.qp-markdown-content {
font-size: 13px;
line-height: 1.5;
color: var(--ac-text);
}
.qp-markdown-content pre {
background-color: var(--ac-surface-muted);
border: var(--ac-border-width) solid var(--ac-border);
border-radius: var(--ac-radius-inner);
padding: 12px;
overflow-x: auto;
margin: 0.5em 0;
}
.qp-markdown-content code {
font-family: var(--ac-font-code);
font-size: 0.875em;
color: var(--ac-text);
}
.qp-markdown-content :not(pre) > code {
background-color: var(--ac-surface-muted);
padding: 0.125em 0.25em;
border-radius: 4px;
}
.qp-markdown-content p {
margin: 0.5em 0;
}
.qp-markdown-content p:first-child {
margin-top: 0;
}
.qp-markdown-content p:last-child {
margin-bottom: 0;
}
.qp-markdown-content ul,
.qp-markdown-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.qp-markdown-content li {
margin: 0.25em 0;
}
.qp-markdown-content h1,
.qp-markdown-content h2,
.qp-markdown-content h3,
.qp-markdown-content h4,
.qp-markdown-content h5,
.qp-markdown-content h6 {
margin: 0.75em 0 0.5em;
font-weight: 600;
line-height: 1.3;
}
.qp-markdown-content h1 { font-size: 1.5em; }
.qp-markdown-content h2 { font-size: 1.3em; }
.qp-markdown-content h3 { font-size: 1.15em; }
.qp-markdown-content h4 { font-size: 1em; }
.qp-markdown-content blockquote {
border-left: 3px solid var(--ac-border-strong);
padding-left: 1em;
margin: 0.5em 0;
color: var(--ac-text-muted);
}
.qp-markdown-content a {
color: var(--ac-link);
text-decoration: underline;
}
.qp-markdown-content a:hover {
color: var(--ac-link-hover);
}
.qp-markdown-content table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
font-size: 0.9em;
}
.qp-markdown-content th,
.qp-markdown-content td {
border: var(--ac-border-width) solid var(--ac-border);
padding: 0.5em;
text-align: left;
}
.qp-markdown-content th {
background-color: var(--ac-surface-muted);
font-weight: 600;
}
.qp-markdown-content hr {
border: none;
border-top: var(--ac-border-width) solid var(--ac-border);
margin: 1em 0;
}
.qp-markdown-content img {
max-width: 100%;
height: auto;
border-radius: var(--ac-radius-inner);
}
.qp-markdown-content strong {
font-weight: 600;
}
.qp-markdown-content em {
font-style: italic;
}
`;
@@ -0,0 +1,243 @@
/**
* @fileoverview Interval Trigger Handler Tests
* @description interval
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';
import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
import { createIntervalTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger';
// ==================== Test Utilities ====================
function createMockLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {
const calls: Array<{ triggerId: string }> = [];
return {
calls,
onFire: vi.fn(async (triggerId) => {
calls.push({ triggerId });
}),
};
}
function createIntervalTriggerSpec(
overrides: Partial<TriggerSpecByKind<'interval'>> = {},
): TriggerSpecByKind<'interval'> {
return {
id: 'interval-trigger-1' as TriggerId,
kind: 'interval',
flowId: 'flow-1' as FlowId,
enabled: true,
periodMinutes: 5,
...overrides,
};
}
// ==================== Mock chrome.alarms ====================
let alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];
let createdAlarms: Map<string, { periodInMinutes?: number; delayInMinutes?: number }> = new Map();
function setupMockChromeAlarms() {
alarmListeners = [];
createdAlarms = new Map();
const alarms = {
create: vi.fn((name: string, info: { periodInMinutes?: number; delayInMinutes?: number }) => {
createdAlarms.set(name, info);
return Promise.resolve();
}),
clear: vi.fn((name: string) => {
createdAlarms.delete(name);
return Promise.resolve(true);
}),
getAll: vi.fn(() => {
return Promise.resolve(
Array.from(createdAlarms.entries()).map(([name]) => ({ name, scheduledTime: 0 })),
);
}),
onAlarm: {
addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
alarmListeners.push(listener);
}),
removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
alarmListeners = alarmListeners.filter((l) => l !== listener);
}),
},
};
(globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };
return alarms;
}
function simulateAlarmFire(name: string) {
for (const listener of alarmListeners) {
listener({ name, scheduledTime: Date.now() });
}
}
// ==================== Tests ====================
describe('IntervalTriggerHandler', () => {
let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;
let mockLogger: ReturnType<typeof createMockLogger>;
let fireCallback: ReturnType<typeof createMockFireCallback>;
beforeEach(() => {
vi.clearAllMocks();
mockAlarms = setupMockChromeAlarms();
mockLogger = createMockLogger();
fireCallback = createMockFireCallback();
});
describe('install', () => {
it('creates repeating alarm with correct periodInMinutes', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec({ periodMinutes: 10 });
await handler.install(trigger);
expect(mockAlarms.create).toHaveBeenCalledWith(
'rr_v3_interval_interval-trigger-1',
expect.objectContaining({
periodInMinutes: 10,
delayInMinutes: 10,
}),
);
});
it('adds alarm listener on first install', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();
await handler.install(createIntervalTriggerSpec());
expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);
});
it('registers trigger ID', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec();
await handler.install(trigger);
expect(handler.getInstalledIds()).toContain(trigger.id);
});
it('throws error for invalid periodMinutes', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
await expect(
handler.install(createIntervalTriggerSpec({ periodMinutes: 0 })),
).rejects.toThrow('periodMinutes must be >= 1');
await expect(
handler.install(createIntervalTriggerSpec({ periodMinutes: -5 })),
).rejects.toThrow('periodMinutes must be >= 1');
await expect(
handler.install(createIntervalTriggerSpec({ periodMinutes: NaN as number })),
).rejects.toThrow('periodMinutes must be a finite number');
});
});
describe('uninstall', () => {
it('clears alarm and removes trigger from installed list', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec();
await handler.install(trigger);
expect(handler.getInstalledIds()).toContain(trigger.id);
await handler.uninstall(trigger.id);
expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_interval_interval-trigger-1');
expect(handler.getInstalledIds()).not.toContain(trigger.id);
});
it('removes alarm listener when last trigger is uninstalled', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec();
await handler.install(trigger);
await handler.uninstall(trigger.id);
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
});
});
describe('uninstallAll', () => {
it('clears all interval alarms', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
await handler.install(createIntervalTriggerSpec({ id: 'trigger-1' as TriggerId }));
await handler.install(createIntervalTriggerSpec({ id: 'trigger-2' as TriggerId }));
await handler.uninstallAll();
expect(handler.getInstalledIds()).toHaveLength(0);
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
});
});
describe('alarm handling', () => {
it('fires callback when alarm triggers', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec();
await handler.install(trigger);
// Simulate alarm fire
simulateAlarmFire('rr_v3_interval_interval-trigger-1');
// Wait for async callback
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).toHaveBeenCalledWith(
trigger.id,
expect.objectContaining({
sourceTabId: undefined,
sourceUrl: undefined,
}),
);
});
it('ignores alarms from other handlers', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
await handler.install(createIntervalTriggerSpec());
// Simulate alarm from different handler
simulateAlarmFire('rr_v3_cron_some-other-trigger');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).not.toHaveBeenCalled();
});
it('ignores alarms for uninstalled triggers', async () => {
const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });
const trigger = createIntervalTriggerSpec();
await handler.install(trigger);
await handler.uninstall(trigger.id);
simulateAlarmFire('rr_v3_interval_interval-trigger-1');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,326 @@
/**
* @fileoverview Once Trigger Handler Tests
* @description once
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';
import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
import { createOnceTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/once-trigger';
// ==================== Test Utilities ====================
function createMockLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {
const calls: Array<{ triggerId: string }> = [];
return {
calls,
onFire: vi.fn(async (triggerId) => {
calls.push({ triggerId });
}),
};
}
function createOnceTriggerSpec(
overrides: Partial<TriggerSpecByKind<'once'>> = {},
): TriggerSpecByKind<'once'> {
return {
id: 'once-trigger-1' as TriggerId,
kind: 'once',
flowId: 'flow-1' as FlowId,
enabled: true,
whenMs: Date.now() + 60000, // 1 minute from now
...overrides,
};
}
// ==================== Mock chrome.alarms ====================
let alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];
let createdAlarms: Map<string, { when?: number }> = new Map();
function setupMockChromeAlarms() {
alarmListeners = [];
createdAlarms = new Map();
const alarms = {
create: vi.fn((name: string, info: { when?: number }) => {
createdAlarms.set(name, info);
return Promise.resolve();
}),
clear: vi.fn((name: string) => {
createdAlarms.delete(name);
return Promise.resolve(true);
}),
getAll: vi.fn(() => {
return Promise.resolve(
Array.from(createdAlarms.entries()).map(([name, info]) => ({
name,
scheduledTime: info.when ?? 0,
})),
);
}),
onAlarm: {
addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
alarmListeners.push(listener);
}),
removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {
alarmListeners = alarmListeners.filter((l) => l !== listener);
}),
},
};
(globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };
return alarms;
}
function simulateAlarmFire(name: string) {
for (const listener of alarmListeners) {
listener({ name, scheduledTime: Date.now() });
}
}
// ==================== Tests ====================
describe('OnceTriggerHandler', () => {
let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;
let mockLogger: ReturnType<typeof createMockLogger>;
let fireCallback: ReturnType<typeof createMockFireCallback>;
let disabledTriggers: Set<TriggerId>;
let mockDisableTrigger: (triggerId: TriggerId) => Promise<void>;
beforeEach(() => {
vi.clearAllMocks();
mockAlarms = setupMockChromeAlarms();
mockLogger = createMockLogger();
fireCallback = createMockFireCallback();
disabledTriggers = new Set();
mockDisableTrigger = vi.fn(async (triggerId: TriggerId) => {
disabledTriggers.add(triggerId);
});
});
describe('install', () => {
it('creates one-shot alarm with correct when timestamp', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const futureTime = Date.now() + 300000; // 5 minutes
const trigger = createOnceTriggerSpec({ whenMs: futureTime });
await handler.install(trigger);
expect(mockAlarms.create).toHaveBeenCalledWith(
'rr_v3_once_once-trigger-1',
expect.objectContaining({ when: futureTime }),
);
});
it('adds alarm listener on first install', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();
await handler.install(createOnceTriggerSpec());
expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);
});
it('registers trigger ID', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
expect(handler.getInstalledIds()).toContain(trigger.id);
});
it('throws error for invalid whenMs', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
await expect(
handler.install(createOnceTriggerSpec({ whenMs: NaN as number })),
).rejects.toThrow('whenMs must be a finite number');
await expect(
handler.install(createOnceTriggerSpec({ whenMs: Infinity as number })),
).rejects.toThrow('whenMs must be a finite number');
});
it('floors whenMs to integer', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec({ whenMs: 1234567890123.999 });
await handler.install(trigger);
expect(mockAlarms.create).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ when: 1234567890123 }),
);
});
});
describe('uninstall', () => {
it('clears alarm and removes trigger from installed list', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
expect(handler.getInstalledIds()).toContain(trigger.id);
await handler.uninstall(trigger.id);
expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_once_once-trigger-1');
expect(handler.getInstalledIds()).not.toContain(trigger.id);
});
it('removes alarm listener when last trigger is uninstalled', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
await handler.uninstall(trigger.id);
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
});
});
describe('uninstallAll', () => {
it('clears all once alarms', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
await handler.install(createOnceTriggerSpec({ id: 'trigger-1' as TriggerId }));
await handler.install(createOnceTriggerSpec({ id: 'trigger-2' as TriggerId }));
await handler.uninstallAll();
expect(handler.getInstalledIds()).toHaveLength(0);
expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();
});
});
describe('alarm handling', () => {
it('fires callback when alarm triggers', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
// Simulate alarm fire
simulateAlarmFire('rr_v3_once_once-trigger-1');
// Wait for async callback
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).toHaveBeenCalledWith(
trigger.id,
expect.objectContaining({
sourceTabId: undefined,
sourceUrl: undefined,
}),
);
});
it('disables trigger after firing', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
simulateAlarmFire('rr_v3_once_once-trigger-1');
// Wait for async callback
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDisableTrigger).toHaveBeenCalledWith(trigger.id);
expect(disabledTriggers.has(trigger.id)).toBe(true);
});
it('uninstalls trigger after firing', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
expect(handler.getInstalledIds()).toContain(trigger.id);
simulateAlarmFire('rr_v3_once_once-trigger-1');
// Wait for async cleanup
await new Promise((resolve) => setTimeout(resolve, 50));
expect(handler.getInstalledIds()).not.toContain(trigger.id);
});
it('ignores alarms from other handlers', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
await handler.install(createOnceTriggerSpec());
// Simulate alarm from different handler
simulateAlarmFire('rr_v3_interval_some-other-trigger');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).not.toHaveBeenCalled();
});
it('ignores alarms for uninstalled triggers', async () => {
const handler = createOnceTriggerHandler(fireCallback, {
logger: mockLogger,
disableTrigger: mockDisableTrigger,
});
const trigger = createOnceTriggerSpec();
await handler.install(trigger);
await handler.uninstall(trigger.id);
simulateAlarmFire('rr_v3_once_once-trigger-1');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fireCallback.onFire).not.toHaveBeenCalled();
});
});
});
@@ -368,6 +368,47 @@ describe('V3 RPC Queue Management APIs', () => {
),
).rejects.toThrow('maxAttempts must be >= 1');
});
it('persists startNodeId in RunRecord when provided', async () => {
// Setup: add a flow with multiple nodes
const flow = createTestFlow('flow-start-node');
getInternal(storage).flowsMap.set(flow.id, flow);
// Act: enqueue with startNodeId
const targetNodeId = flow.nodes[0].id; // Use the first node
await (server as unknown as { handleRequest: Function }).handleRequest(
{
method: 'rr_v3.enqueueRun',
params: { flowId: 'flow-start-node', startNodeId: targetNodeId },
requestId: 'req-1',
},
{ subscriptions: new Set() },
);
// Assert: RunRecord should have startNodeId
const runsMap = getInternal(storage).runsMap;
expect(runsMap.size).toBe(1);
const runRecord = Array.from(runsMap.values())[0];
expect(runRecord.startNodeId).toBe(targetNodeId);
});
it('throws if startNodeId does not exist in flow', async () => {
// Setup: add a flow
const flow = createTestFlow('flow-invalid-start');
getInternal(storage).flowsMap.set(flow.id, flow);
// Act & Assert
await expect(
(server as unknown as { handleRequest: Function }).handleRequest(
{
method: 'rr_v3.enqueueRun',
params: { flowId: 'flow-invalid-start', startNodeId: 'non-existent-node' },
requestId: 'req-1',
},
{ subscriptions: new Set() },
),
).rejects.toThrow('startNodeId "non-existent-node" not found in flow');
});
});
describe('rr_v3.listQueue', () => {
@@ -0,0 +1,344 @@
/**
* @fileoverview V2 to V3 Flow Conversion Tests
* @description V2V3 entryNodeId
*/
import { describe, it, expect } from 'vitest';
import {
convertFlowV2ToV3,
convertFlowV3ToV2,
} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';
// ==================== Test Helpers ====================
function createV2Flow(overrides: Partial<Parameters<typeof convertFlowV2ToV3>[0]> = {}) {
return {
id: 'test-flow',
name: 'Test Flow',
version: 2,
nodes: [],
edges: [],
...overrides,
};
}
// ==================== entryNodeId Calculation Tests ====================
describe('convertFlowV2ToV3 - entryNodeId calculation', () => {
describe('basic scenarios', () => {
it('selects the only executable node as entry', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [{ id: 'nav-1', type: 'navigate' }],
edges: [],
}),
);
expect(result.success).toBe(true);
expect(result.data?.entryNodeId).toBe('nav-1');
expect(result.warnings).toHaveLength(0);
});
it('selects node with inDegree=0 as entry', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-1', type: 'navigate' },
{ id: 'click-1', type: 'click' },
],
edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],
}),
);
expect(result.success).toBe(true);
expect(result.data?.entryNodeId).toBe('nav-1');
});
});
describe('trigger node handling', () => {
it('ignores trigger node when selecting entry', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'trigger-1', type: 'trigger' },
{ id: 'nav-1', type: 'navigate' },
],
edges: [],
}),
);
expect(result.success).toBe(true);
expect(result.data?.entryNodeId).toBe('nav-1');
});
it('ignores edges from trigger node when calculating inDegree', () => {
// Scenario: trigger → navigate → click
// Without this fix, navigate would have inDegree=1 and not be selected
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'trigger-1', type: 'trigger' },
{ id: 'nav-1', type: 'navigate' },
{ id: 'click-1', type: 'click' },
],
edges: [
{ id: 'e1', from: 'trigger-1', to: 'nav-1' },
{ id: 'e2', from: 'nav-1', to: 'click-1' },
],
}),
);
expect(result.success).toBe(true);
// navigate should be entry because trigger edges are ignored
expect(result.data?.entryNodeId).toBe('nav-1');
});
it('returns error when only trigger nodes exist', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [{ id: 'trigger-1', type: 'trigger' }],
edges: [],
}),
);
expect(result.success).toBe(false);
expect(result.errors).toContain('Could not determine entry node. No valid root node found.');
});
});
describe('multiple root nodes - stable selection', () => {
it('warns and selects by UI coordinates (leftmost, then topmost)', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-b', type: 'navigate', ui: { x: 200, y: 100 } },
{ id: 'nav-a', type: 'navigate', ui: { x: 100, y: 200 } },
{ id: 'nav-c', type: 'navigate', ui: { x: 100, y: 100 } },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// nav-c has smallest x, and smallest y at that x
expect(result.data?.entryNodeId).toBe('nav-c');
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings.some((w) => w.includes('Multiple inDegree=0'))).toBe(true);
expect(result.warnings.some((w) => w.includes('ui(x=100, y=100)'))).toBe(true);
});
it('selects by ID when no UI coordinates available', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-b', type: 'navigate' },
{ id: 'nav-a', type: 'navigate' },
{ id: 'nav-c', type: 'navigate' },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// nav-a comes first alphabetically
expect(result.data?.entryNodeId).toBe('nav-a');
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
});
it('uses UI for nodes that have it, ignoring nodes without UI', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-a', type: 'navigate' }, // no UI
{ id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// nav-b has UI coordinates, so it's preferred
expect(result.data?.entryNodeId).toBe('nav-b');
});
});
describe('cycle detection', () => {
it('falls back using stable selection when graph has cycle (no inDegree=0)', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-1', type: 'navigate' },
{ id: 'click-1', type: 'click' },
],
edges: [
{ id: 'e1', from: 'nav-1', to: 'click-1' },
{ id: 'e2', from: 'click-1', to: 'nav-1' },
],
}),
);
expect(result.success).toBe(true);
expect(result.data?.entryNodeId).toBeTruthy();
expect(result.warnings.some((w) => w.includes('cycles'))).toBe(true);
});
it('uses stable selection (by id) for cycle fallback', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'z-node', type: 'navigate' },
{ id: 'a-node', type: 'click' },
],
edges: [
{ id: 'e1', from: 'z-node', to: 'a-node' },
{ id: 'e2', from: 'a-node', to: 'z-node' },
],
}),
);
expect(result.success).toBe(true);
// Should select 'a-node' as it comes first alphabetically
expect(result.data?.entryNodeId).toBe('a-node');
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
});
it('uses stable selection (by UI) for cycle fallback when UI available', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'a-node', type: 'navigate', ui: { x: 200, y: 100 } },
{ id: 'z-node', type: 'click', ui: { x: 100, y: 100 } },
],
edges: [
{ id: 'e1', from: 'a-node', to: 'z-node' },
{ id: 'e2', from: 'z-node', to: 'a-node' },
],
}),
);
expect(result.success).toBe(true);
// Should select 'z-node' as it has smaller x coordinate
expect(result.data?.entryNodeId).toBe('z-node');
expect(result.warnings.some((w) => w.includes('ui(x=100'))).toBe(true);
});
});
describe('UI coordinate edge cases', () => {
it('treats NaN coordinates as invalid UI', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-a', type: 'navigate', ui: { x: NaN, y: 100 } },
{ id: 'nav-b', type: 'navigate' },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// Both nodes have no valid UI, should use ID sorting
expect(result.data?.entryNodeId).toBe('nav-a');
expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);
});
it('treats Infinity coordinates as invalid UI', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-a', type: 'navigate', ui: { x: Infinity, y: 100 } },
{ id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// Only nav-b has valid UI
expect(result.data?.entryNodeId).toBe('nav-b');
});
it('uses id as tie-breaker when UI coordinates are equal', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [
{ id: 'nav-z', type: 'navigate', ui: { x: 100, y: 100 } },
{ id: 'nav-a', type: 'navigate', ui: { x: 100, y: 100 } },
],
edges: [],
}),
);
expect(result.success).toBe(true);
// Same coordinates, should use ID as tie-breaker
expect(result.data?.entryNodeId).toBe('nav-a');
});
});
describe('empty and error cases', () => {
it('returns error when no nodes exist', () => {
const result = convertFlowV2ToV3(
createV2Flow({
nodes: [],
edges: [],
}),
);
expect(result.success).toBe(false);
expect(result.errors).toContain('V2 Flow has no nodes');
});
});
});
// ==================== Roundtrip Tests ====================
describe('V2 <-> V3 roundtrip conversion', () => {
it('preserves basic flow structure through roundtrip', () => {
const original = createV2Flow({
name: 'Roundtrip Test',
description: 'Test description',
nodes: [
{ id: 'nav-1', type: 'navigate', config: { url: 'https://example.com' } },
{ id: 'click-1', type: 'click', config: { selector: '#btn' } },
],
edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],
});
const toV3 = convertFlowV2ToV3(original);
expect(toV3.success).toBe(true);
const backToV2 = convertFlowV3ToV2(toV3.data!);
expect(backToV2.success).toBe(true);
// Check structure preserved
expect(backToV2.data?.name).toBe(original.name);
expect(backToV2.data?.description).toBe(original.description);
expect(backToV2.data?.nodes).toHaveLength(2);
expect(backToV2.data?.edges).toHaveLength(1);
});
it('preserves node configs through roundtrip', () => {
const original = createV2Flow({
nodes: [
{
id: 'nav-1',
type: 'navigate',
name: 'Go to site',
disabled: true,
config: { url: 'https://example.com', waitUntil: 'load' },
ui: { x: 100, y: 200 },
},
],
edges: [],
});
const toV3 = convertFlowV2ToV3(original);
const backToV2 = convertFlowV3ToV2(toV3.data!);
const node = backToV2.data?.nodes?.[0];
expect(node?.type).toBe('navigate');
expect(node?.name).toBe('Go to site');
expect(node?.disabled).toBe(true);
expect(node?.config).toEqual({ url: 'https://example.com', waitUntil: 'load' });
expect(node?.ui).toEqual({ x: 100, y: 200 });
});
});
+6 -6
View File
@@ -84,16 +84,16 @@ export default defineConfig({
// suggested_key: { default: 'Ctrl+Shift+3' },
// description: 'Run quick trigger 3',
// },
open_workflow_sidepanel: {
suggested_key: { default: 'Ctrl+Shift+O' },
description: 'Open workflow sidepanel',
},
// open_workflow_sidepanel: {
// suggested_key: { default: 'Ctrl+Shift+O' },
// description: 'Open workflow sidepanel',
// },
toggle_web_editor: {
suggested_key: { default: 'Ctrl+Shift+E', mac: 'Command+Shift+E' },
suggested_key: { default: 'Ctrl+Shift+O', mac: 'Command+Shift+O' },
description: 'Toggle Web Editor mode',
},
toggle_quick_panel: {
suggested_key: { default: 'Ctrl+Shift+K', mac: 'Command+Shift+K' },
suggested_key: { default: 'Ctrl+Shift+U', mac: 'Command+Shift+U' },
description: 'Toggle Quick Panel AI Chat',
},
},
+8
View File
@@ -0,0 +1,8 @@
# Development-only files that should not be published to npm
# node_path.txt contains the absolute path to Node.js used during build.
# It's written by build.ts for development hot-reload, but is useless
# (and potentially confusing) in the published package since users will
# have their own Node.js path written by postinstall.
node_path.txt
**/node_path.txt
+2 -1
View File
@@ -20,7 +20,8 @@
"postinstall": "node dist/scripts/postinstall.js"
},
"files": [
"dist"
"dist",
"!dist/node_path.txt"
],
"engines": {
"node": ">=20.0.0"
@@ -16,6 +16,7 @@ import {
getSession,
updateEngineSessionId,
updateManagementInfo,
touchSessionActivity,
type AgentSession,
} from './session-service';
import { attachmentService, type SavedAttachment } from './attachment-service';
@@ -226,6 +227,10 @@ export class AgentChatService {
// Persist user message into project history for later reload.
try {
await touchProjectActivity(projectId);
// Update session activity timestamp so it appears at top of session list
if (dbSessionId) {
await touchSessionActivity(dbSessionId);
}
await persistAgentMessage({
projectId,
role: 'user',
+124
View File
@@ -15,6 +15,8 @@
* - Linux: gnome-terminal, konsole, xfce4-terminal, xterm
*/
import os from 'node:os';
import path from 'node:path';
import { stat } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import type { OpenProjectResponse, OpenProjectTarget } from 'chrome-mcp-shared';
import { validateRootPath } from './project-service';
@@ -207,6 +209,128 @@ async function openInVSCode(absolutePath: string): Promise<void> {
await runFallbackSequence(`Failed to open VS Code for: ${absolutePath}`, attempts);
}
/**
* Open a file in VS Code at a specific line/column.
*
* Uses 'code -g file:line:col' syntax for goto functionality.
* Also opens the project root with -r to reuse existing window.
*
* Security:
* - Validates that file path stays within project root
* - Uses spawn with args array (no shell interpolation)
*
* @param projectRoot - Project root directory (for security validation and -r flag)
* @param filePath - File path (relative or absolute)
* @param line - Optional line number (1-based)
* @param column - Optional column number (1-based)
*/
export async function openFileInVSCode(
projectRoot: string,
filePath: string,
line?: number,
column?: number,
): Promise<OpenProjectResponse> {
try {
// Validate project root
const projectValidation = await validateRootPath(projectRoot);
if (!projectValidation.valid) {
return {
success: false,
error: projectValidation.error ?? 'Invalid project rootPath',
};
}
if (!projectValidation.exists) {
return {
success: false,
error: `Project directory does not exist: ${projectValidation.absolute}`,
};
}
const rootAbs = projectValidation.absolute;
// Validate file path
const trimmedFile = String(filePath ?? '').trim();
if (!trimmedFile) {
return { success: false, error: 'filePath is required' };
}
// Resolve file path (relative paths are resolved against project root)
const absoluteFile = path.isAbsolute(trimmedFile)
? path.resolve(trimmedFile)
: path.resolve(rootAbs, trimmedFile);
// Security: ensure file stays within project root
const relativeToRoot = path.relative(rootAbs, absoluteFile);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
return { success: false, error: 'File path must be within project directory' };
}
// Check file exists (best-effort, don't fail hard if file is new)
try {
const fileStat = await stat(absoluteFile);
if (!fileStat.isFile()) {
return { success: false, error: `Not a file: ${absoluteFile}` };
}
} catch {
return { success: false, error: `File does not exist: ${absoluteFile}` };
}
// Validate and sanitize line/column
const safeLine =
typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined;
const safeColumn =
typeof column === 'number' && Number.isFinite(column) && column > 0
? Math.floor(column)
: undefined;
// Build goto argument: file:line:col
let gotoArg = absoluteFile;
if (safeLine) {
gotoArg += `:${safeLine}`;
if (safeColumn) {
gotoArg += `:${safeColumn}`;
}
}
const platform = os.platform();
// Build launch attempts
// Use -r to reuse existing window, -g for goto
const attempts: LaunchAttempt[] = [
{
label: 'code -r -g',
cmd: 'code',
args: ['-r', rootAbs, '-g', gotoArg],
successAfterMs: 8000,
},
];
if (platform === 'win32') {
attempts.push({
label: 'code.cmd -r -g',
cmd: 'code.cmd',
args: ['-r', rootAbs, '-g', gotoArg],
successAfterMs: 8000,
});
}
if (platform === 'darwin') {
// macOS: use --args to pass flags to VS Code
attempts.push({
label: 'open -b com.microsoft.VSCode --args',
cmd: 'open',
args: ['-b', 'com.microsoft.VSCode', '--args', '-r', rootAbs, '-g', gotoArg],
successAfterMs: 3000,
});
}
await runFallbackSequence(`Failed to open VS Code for: ${gotoArg}`, attempts);
return { success: true };
} catch (error) {
return { success: false, error: formatSpawnError(error) };
}
}
// ============================================================
// Terminal
// ============================================================
@@ -427,6 +427,16 @@ export async function updateEngineSessionId(
await updateSession(sessionId, { engineSessionId });
}
/**
* Touch session activity - updates the updatedAt timestamp.
* Used when a message is sent to move the session to the top of the list.
*/
export async function touchSessionActivity(sessionId: string): Promise<void> {
const db = getDb();
const now = new Date().toISOString();
await db.update(sessions).set({ updatedAt: now }).where(eq(sessions.id, sessionId));
}
/**
* Update the cached management information.
*/
+2 -15
View File
@@ -8,25 +8,12 @@ import {
colorText,
registerWithElevatedPermissions,
ensureExecutionPermissions,
writeNodePathFile,
} from './scripts/utils';
import { BrowserType, parseBrowserType, detectInstalledBrowsers } from './scripts/browser-config';
import { runDoctor } from './scripts/doctor';
import { runReport } from './scripts/report';
// Import writeNodePath from postinstall
async function writeNodePath(): Promise<void> {
try {
const nodePath = process.execPath;
const nodePathFile = path.join(__dirname, 'node_path.txt');
console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
fs.writeFileSync(nodePathFile, nodePath, 'utf8');
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
} catch (error: any) {
console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
}
}
program
.version(require('../package.json').version)
.description('Mcp Chrome Bridge - Local service for communicating with Chrome extension');
@@ -42,7 +29,7 @@ program
.action(async (options) => {
try {
// Write Node.js path for run_host scripts
await writeNodePath();
writeNodePathFile(__dirname);
// Determine which browsers to register
let targetBrowsers: BrowserType[] | undefined;
+8
View File
@@ -118,4 +118,12 @@ filesToMakeExecutable.forEach((file) => {
}
});
// Write node_path.txt immediately after build to ensure Chrome uses the correct Node.js version.
// This is critical for development mode where dist is deleted on each rebuild.
// The file points to the same Node.js that compiled the native modules (better-sqlite3 etc.)
console.log('写入 node_path.txt...');
const nodePathFile = path.join(distDir, 'node_path.txt');
fs.writeFileSync(nodePathFile, process.execPath, 'utf8');
console.log(`已写入 Node.js 路径: ${process.execPath}`);
console.log('✅ 构建完成');
+2 -18
View File
@@ -4,7 +4,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { COMMAND_NAME } from './constant';
import { colorText, tryRegisterUserLevelHost } from './utils';
import { colorText, tryRegisterUserLevelHost, writeNodePathFile } from './utils';
// Check if this script is run directly
const isDirectRun = require.main === module;
@@ -73,22 +73,6 @@ function isRunningElevated(): boolean {
}
}
/**
* Write Node.js path for run_host scripts to avoid fragile relative paths
*/
async function writeNodePath(): Promise<void> {
try {
const nodePath = process.execPath;
const nodePathFile = path.join(__dirname, '..', 'node_path.txt');
console.log(colorText(`Writing Node.js path: ${nodePath}`, 'blue'));
fs.writeFileSync(nodePathFile, nodePath, 'utf8');
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
} catch (error: any) {
console.warn(colorText(`⚠️ Failed to write Node.js path: ${error.message}`, 'yellow'));
}
}
/**
*
*/
@@ -310,7 +294,7 @@ async function main(): Promise<void> {
await ensureExecutionPermissions();
// Write Node.js path for run_host scripts to use
await writeNodePath();
writeNodePathFile(path.join(__dirname, '..'));
// If global installation, try automatic registration
if (isGlobalInstall) {
@@ -1,3 +1,3 @@
import { tryRegisterUserLevelHost } from './utils';
import { registerUserLevelHostWithNodePath } from './utils';
tryRegisterUserLevelHost();
registerUserLevelHostWithNodePath();
+5 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import path from 'path';
import { COMMAND_NAME } from './constant';
import { colorText, registerWithElevatedPermissions } from './utils';
import { colorText, registerWithElevatedPermissions, writeNodePathFile } from './utils';
/**
*
@@ -9,6 +10,9 @@ async function main(): Promise<void> {
console.log(colorText(`正在注册 ${COMMAND_NAME} Native Messaging主机...`, 'blue'));
try {
// Write Node.js path before registration
writeNodePathFile(path.join(__dirname, '..'));
await registerWithElevatedPermissions();
console.log(
colorText('注册成功!现在Chrome扩展可以通过Native Messaging与本地服务通信。', 'green'),
+37
View File
@@ -124,6 +124,28 @@ export async function getMainPath(): Promise<string> {
}
}
/**
* Write Node.js executable path to node_path.txt for run_host scripts.
* This ensures the native host uses the same Node.js version that was used during installation,
* avoiding NODE_MODULE_VERSION mismatch errors with native modules like better-sqlite3.
*
* @param distDir - The dist directory where node_path.txt should be written
* @param nodeExecPath - The Node.js executable path to write (defaults to current process.execPath)
*/
export function writeNodePathFile(distDir: string, nodeExecPath = process.execPath): void {
try {
const nodePathFile = path.join(distDir, 'node_path.txt');
fs.mkdirSync(distDir, { recursive: true });
console.log(colorText(`Writing Node.js path: ${nodeExecPath}`, 'blue'));
fs.writeFileSync(nodePathFile, nodeExecPath, 'utf8');
console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(colorText(`⚠️ Failed to write Node.js path: ${message}`, 'yellow'));
}
}
/**
*
*/
@@ -259,6 +281,21 @@ function verifyWindowsRegistryEntry(registryKey: string, expectedPath: string):
return false;
}
/**
* Write node_path.txt and then register user-level Native Messaging host.
* This is the recommended entry point for development and production registration,
* as it ensures the Node.js path is captured before registration.
*
* @param browsers - Optional list of browsers to register for
* @returns true if at least one browser was registered successfully
*/
export async function registerUserLevelHostWithNodePath(
browsers?: BrowserType[],
): Promise<boolean> {
writeNodePathFile(path.join(__dirname, '..'));
return tryRegisterUserLevelHost(browsers);
}
/**
* Native Messaging主机
*/
+61 -1
View File
@@ -46,7 +46,7 @@ import { getDefaultWorkspaceDir, getDefaultProjectRoot } from '../../agent/stora
import { openDirectoryPicker } from '../../agent/directory-picker';
import type { EngineName } from '../../agent/engines/types';
import { attachmentService } from '../../agent/attachment-service';
import { openProjectDirectory } from '../../agent/open-project';
import { openProjectDirectory, openFileInVSCode } from '../../agent/open-project';
import type {
AttachmentStatsResponse,
AttachmentCleanupRequest,
@@ -727,6 +727,66 @@ export function registerAgentRoutes(fastify: FastifyInstance, options: AgentRout
},
);
/**
* POST /agent/projects/:projectId/open-file
* Open a file in VSCode at a specific line/column.
*
* Request body:
* - filePath: string (required) - File path (relative or absolute)
* - line?: number - Line number (1-based)
* - column?: number - Column number (1-based)
*/
fastify.post(
'/agent/projects/:projectId/open-file',
async (
request: FastifyRequest<{
Params: { projectId: string };
Body: { filePath?: string; line?: number; column?: number };
}>,
reply: FastifyReply,
) => {
const { projectId } = request.params;
const { filePath, line, column } = request.body || {};
if (!projectId) {
return reply
.status(HTTP_STATUS.BAD_REQUEST)
.send({ success: false, error: 'projectId is required' });
}
if (!filePath || typeof filePath !== 'string') {
return reply
.status(HTTP_STATUS.BAD_REQUEST)
.send({ success: false, error: 'filePath is required' });
}
try {
const project = await getProject(projectId);
if (!project) {
return reply
.status(HTTP_STATUS.NOT_FOUND)
.send({ success: false, error: 'Project not found' });
}
// Open the file in VSCode
const result = await openFileInVSCode(project.rootPath, filePath, line, column);
if (result.success) {
return reply.status(HTTP_STATUS.OK).send({ success: true });
}
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
success: false,
error: result.error,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
fastify.log.error({ err: error }, 'Failed to open file in VSCode');
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
success: false,
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
});
}
},
);
// ============================================================
// Chat Message Routes
// ============================================================
+240
View File
@@ -0,0 +1,240 @@
# Record-Replay V3 重构任务清单
## 项目概述
将 Chrome 扩展的录制回放系统从 V2 架构完全迁移到 V3 架构,包括 Builder UI、节点扩展、触发器系统等。
## 当前状态
- **Phase 0-3**: 全部完成 ✅
- **Phase 4 (触发器系统)**: 全部完成 ✅
- ✅ P4-01 TriggerStore CRUD
- ✅ P4-02 TriggerManager
- ✅ P4-03 URL trigger
- ✅ P4-04 Command trigger
- ✅ P4-05 ContextMenu trigger
- ✅ P4-06 DOM trigger
- ✅ P4-07 Cron trigger
- ✅ P4-08 防抖/防风暴 (cooldown/maxQueued 已实现)
- ✅ P4-09 触发器管理 RPC API
- **Milestone 1 (Builder 核心链路 V3 化)**: 全部完成 ✅
- ✅ 1.1 共享 RPC 层
- ✅ 1.2 startNodeId 端到端补完
- ✅ 1.3 Builder RPC 迁移
- **Milestone 2 (Builder 数据层 V3 化)**: 部分完成 🔄
- ⏸️ 2.1 类型迁移 - 评估后延后(当前转换层工作良好)
- ✅ 2.2 entryNodeId 计算优化
- ✅ 2.3 Sidebar Flow 分类 bug 修复
- **Milestone 3 (触发器扩展)**: 全部完成 ✅
- ✅ 3.1 interval/once TriggerKind
- ✅ 3.2 Trigger 独立面板
- **Milestone 4 (节点扩展)**: 进行中 🔄
- ✅ 4.1 triggerEvent/setAttribute ActionHandler
- ⏳ 4.2 V3 Control Flow 基础设施
- ⏳ 4.3 foreach/while/loopElements 节点
- ⏳ 4.4 executeFlow 节点
- **Milestone 5 (清理 V2 代码)**: 待开始
- **测试状态**: 641 个测试全部通过
- **下一步**: Milestone 4.2 (Control Flow 基础设施)
---
## 已完成任务详情
### Milestone 1: Builder 核心链路 V3 化 ✅
#### 1.1 共享 RPC 层 ✅
- 新建: `entrypoints/shared/composables/useRRV3Rpc.ts`
- 新建: `entrypoints/shared/composables/index.ts`
- 修改: `entrypoints/sidepanel/composables/useRRV3Rpc.ts` → re-export
#### 1.2 startNodeId 端到端补完 ✅
- `EnqueueRunInput` 新增 `startNodeId?: NodeId`
- 在 `enqueue-run.ts` 添加 startNodeId 校验和持久化
- 在 `rpc-server.ts` 传递 `params.startNodeId`
- 新增 2 个测试用例
#### 1.3 Builder RPC 迁移 ✅
- 新建: `entrypoints/shared/utils/rr-flow-convert.ts` (V2/V3 双向转换)
- 修改 `App.vue`: bootstrap/save/run/sync/export/import 全面 V3 化
### Milestone 2: Builder 数据层 V3 化
#### 2.1 类型迁移 ⏸️ (延后)
当前 V2/V3 转换层工作良好,暂不迁移。
#### 2.2 entryNodeId 计算优化 ✅
- 重构 `findEntryNodeId` 函数(忽略 trigger 指出的边)
- 新增 `selectStableRootNode` 函数(稳定的多根节点选择)
- 17 个测试用例
#### 2.3 Sidebar Flow 分类 bug 修复 ✅
- Sidebar.vue 增加 `Flow` 分类
- trigger/executeFlow 节点移到 Flow 分类
### Milestone 3: 触发器系统扩展 ✅
#### 3.1 interval/once TriggerKind ✅
- `TriggerKind` 新增 `'interval' | 'once'`
- 新建 `interval-trigger.ts` (chrome.alarms.periodInMinutes)
- 新建 `once-trigger.ts` (chrome.alarms.when + 自动禁用)
- 23 个测试用例
#### 3.2 Trigger 独立面板 ✅
- 新建 `TriggerPanel.vue` (浮动面板)
- 支持 interval/once CRUD (panel-managed)
- node-managed 触发器只读展示 + 禁用 toggle
- ownership 模型区分触发器来源
### Milestone 4: 节点扩展
#### 4.1 triggerEvent / setAttribute ✅
**实现为 V2 ActionHandler(自动被 V3 复用)**
新建文件:
- `entrypoints/background/record-replay/actions/handlers/dom.ts`
- `triggerEventHandler`: 在元素上触发自定义 DOM 事件
- `setAttributeHandler`: 设置/删除元素属性
修改文件:
- `entrypoints/background/record-replay/actions/handlers/index.ts`
- 导入并注册 handler
- 更新 `ALL_HANDLERS``registerReplayHandlers`
设计决策:
- 实现为 V2 ActionHandlerV3 通过 `registerV2ReplayNodesAsV3Nodes` 自动复用
- 使用 `resolveTargetSelector` 共享目标解析逻辑
- 脚本执行错误区分 `TARGET_NOT_FOUND` vs `SCRIPT_FAILED`
---
## 待完成任务
### Milestone 4.2: V3 Control Flow 基础设施 ⏳
**目标**: 扩展 V3 runner 支持 control directives 和 subflows
**设计决策(已确定)**:
1. subflows 存储在 FlowV3 顶层 `subflows?: Record<SubflowId, SubflowV3>`
2. subflow 在同一个 Runner 内递归执行(不创建新 RunRunner)
3. 变量作用域:foreach 共享 vars,每次迭代设置 itemVar
4. 不支持并发执行(concurrency > 1 报错)
5. 事件流:subflow 内节点照常发 node.started/node.succeeded
**文件变更**:
- `domain/flow.ts`: 添加 `SubflowV3` 类型,FlowV3 添加 `subflows` 字段
- `engine/plugins/types.ts`: `NodeExecutionResult` 添加 `control` 字段
- `engine/kernel/runner.ts`: 抽象 `runGraph()` 方法,实现 control directive 处理
- `engine/plugins/v2-action-adapter.ts`: 移除 control 排除逻辑,改为返回 control
- `engine/transport/rpc-server.ts`: `normalizeFlowSpec` 支持 subflows
- `storage/flows.ts`: 存储校验支持 subflows
### Milestone 4.3: foreach/while/loopElements 节点 ⏳
**依赖**: 4.2 Control Flow 基础设施
**文件变更**:
- 新建: `engine/plugins/nodes/foreach.ts`
- 新建: `engine/plugins/nodes/while.ts`
- 新建: `engine/plugins/nodes/loop-elements.ts`
- 复用表达式求值器: `record-replay/engine/utils/expression.ts`
### Milestone 4.4: executeFlow 节点 ⏳
**关键设计**:
- 不走 enqueueRun(避免死锁)
- 作为 control directive 由 runner 直接子执行
- `inline=true`: 共享 vars
- `inline=false`: clone vars
- 递归防护: 维护 flowId 调用栈检测环
**文件变更**:
- 新建: `engine/plugins/nodes/execute-flow.ts`
### Milestone 5: 清理 V2 代码 ⏳
#### 5.1 删除 V2 兼容代码
- `storage/import/v2-to-v3.ts`
- `storage/import/v2-reader.ts`
#### 5.2 删除 V2 消息通道
- `builder/App.vue` 移除 `BACKGROUND_MESSAGE_TYPES.RR_*`
- 逐步移除 `entrypoints/background/record-replay/` 相关代码
---
## 实施优先级与依赖关系
```
Milestone 1: Builder 核心链路 V3 化 ✅
├── 1.1 共享 RPC 层 ✅
├── 1.2 startNodeId 端到端 ✅
└── 1.3 Builder RPC 迁移 ✅
Milestone 2: Builder 数据层 V3 化
├── 2.1 类型迁移 ⏸️
├── 2.2 entryNodeId 计算 ✅
└── 2.3 Sidebar bug 修复 ✅
├─────────────────────────────┐
▼ ▼
Milestone 3: 触发器扩展 ✅ Milestone 4.1: 简单节点 ✅
├── 3.1 interval/once ✅ ├── triggerEvent ✅
└── 3.2 Trigger 面板 ✅ └── setAttribute ✅
Milestone 4.2: Control Flow 基础 ⏳
└── subflows + control directives
Milestone 4.3: 循环节点 ⏳
├── foreach
├── while
└── loopElements
Milestone 4.4: executeFlow ⏳
Milestone 5: 清理 V2 代码 ⏳
```
## 风险与缓解
| 风险 | 缓解措施 |
| ----------------------------------- | ------------------------------------------- |
| entryNodeId 计算错误导致保存失败 | 复用已测试的 v2-to-v3.ts 规则,增加 UI 提示 |
| startNodeId 不存在导致运行失败 | 在 enqueue-run.ts 校验存在性 |
| control flow 复杂导致 runner 不稳定 | 渐进式实现,每步都有测试覆盖 |
| executeFlow 递归死锁 | 维护调用栈检测环,不走 enqueueRun |
| Trigger 批量保存造成抖动 | 节流/批处理策略 |
## 测试策略
- 每个 Milestone 完成后运行全量测试
- 新增功能必须有对应的契约测试
- 节点扩展需要覆盖正常/异常/边界用例
---
_最后更新: 2025-12-29_
+131
View File
@@ -108,6 +108,16 @@ export interface AgentActRequest {
* Optional request id from client; server will generate one if missing.
*/
requestId?: string;
/**
* Optional client metadata to store with the user message.
* For extension-specific context that should be preserved.
*/
clientMeta?: Record<string, unknown>;
/**
* Optional display text override for the instruction.
* When set, UI should display this instead of raw instruction.
*/
displayText?: string;
}
export interface AgentActResponse {
@@ -140,6 +150,11 @@ export interface AgentProject {
* When enabled, the engine will auto-detect CCR configuration.
*/
useCcr?: boolean;
/**
* Whether to enable Chrome MCP integration for this project.
* Default: true
*/
enableChromeMcp?: boolean;
createdAt: string;
updatedAt: string;
lastActiveAt?: string;
@@ -344,3 +359,119 @@ export const DEFAULT_CODEX_CONFIG: CodexEngineConfig = {
autoInstructions: CODEX_AUTO_INSTRUCTIONS,
appendProjectContext: true,
};
// ============================================================
// Attachment Types
// ============================================================
/**
* Metadata for a persisted attachment file.
*/
export interface AttachmentMetadata {
/** Schema version for forward compatibility */
version: number;
/** Kind of attachment (e.g., 'image', 'file') */
kind: string;
/** Project ID this attachment belongs to */
projectId: string;
/** Message ID this attachment is associated with */
messageId: string;
/** Index of this attachment in the message */
index: number;
/** Persisted filename under project dir */
filename: string;
/** URL path to access this attachment */
urlPath: string;
/** MIME type of the attachment */
mimeType: string;
/** File size in bytes */
sizeBytes: number;
/** Original filename from upload */
originalName: string;
/** Timestamp when attachment was created */
createdAt: string;
}
/**
* Statistics for attachments in a single project.
*/
export interface AttachmentProjectStats {
projectId: string;
/** Directory path for this project's attachments */
dirPath: string;
/** Whether the directory exists */
exists: boolean;
fileCount: number;
totalBytes: number;
/** Last modification timestamp (only when exists is true) */
lastModifiedAt?: string;
}
/**
* Cleanup result for a single project.
*/
export interface CleanupProjectResult {
projectId: string;
dirPath: string;
existed: boolean;
removedFiles: number;
removedBytes: number;
}
/**
* Response for attachment statistics endpoint.
*/
export interface AttachmentStatsResponse {
success: boolean;
rootDir: string;
totalFiles: number;
totalBytes: number;
projects: Array<
AttachmentProjectStats & {
projectName?: string;
existsInDb: boolean;
}
>;
orphanProjectIds: string[];
}
/**
* Request body for attachment cleanup endpoint.
*/
export interface AttachmentCleanupRequest {
/** If provided, cleanup only these projects. Otherwise cleanup all. */
projectIds?: string[];
}
/**
* Response for attachment cleanup endpoint.
*/
export interface AttachmentCleanupResponse {
success: boolean;
scope: 'project' | 'selected' | 'all';
removedFiles: number;
removedBytes: number;
results: CleanupProjectResult[];
}
// ============================================================
// Open Project Types
// ============================================================
/**
* Target application for opening a project directory.
*/
export type OpenProjectTarget = 'vscode' | 'terminal';
/**
* Request body for open-project endpoint.
*/
export interface OpenProjectRequest {
/** Target application to open the project in */
target: OpenProjectTarget;
}
/**
* Response for open-project endpoint.
*/
export type OpenProjectResponse = { success: true } | { success: false; error: string };
+3 -3
View File
@@ -424,7 +424,7 @@ export function registerBuiltinSpecs() {
registerNodeSpec({
type: 'executeFlow' as any,
version: 1,
display: { label: '执行子流程', iconClass: 'icon-flow', category: 'Tools' },
display: { label: '执行子流程', iconClass: 'icon-exec', category: 'Flow' },
ports: { inputs: 1, outputs: [{ label: 'default' }] },
schema: [
{ key: 'flowId', label: '流程ID', type: 'string', required: true },
@@ -545,11 +545,11 @@ export function registerBuiltinSpecs() {
defaults: { sleep: 1000 },
});
// Trigger (builder-only)
// Trigger (builder-only, flow-level node)
registerNodeSpec({
type: STEP_TYPES.TRIGGER,
version: 1,
display: { label: '触发器', iconClass: 'icon-trigger', category: 'Actions' },
display: { label: '触发器', iconClass: 'icon-trigger', category: 'Flow' },
ports: { inputs: 0, outputs: [{ label: 'default' }] },
schema: [
{ key: 'enabled', label: '启用', type: 'boolean', default: true },
+52
View File
@@ -11,6 +11,7 @@ export const TOOL_NAMES = {
WEB_FETCHER: 'chrome_get_web_content',
CLICK: 'chrome_click_element',
FILL: 'chrome_fill_or_select',
REQUEST_ELEMENT_SELECTION: 'chrome_request_element_selection',
GET_INTERACTIVE_ELEMENTS: 'chrome_get_interactive_elements',
NETWORK_CAPTURE: 'chrome_network_capture',
// Legacy tool names (kept for internal use, not exposed in TOOL_SCHEMAS)
@@ -975,6 +976,57 @@ export const TOOL_SCHEMAS: Tool[] = [
required: ['value'],
},
},
{
name: TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION,
description:
'Request the user to manually select one or more elements on the current page. Use this as a human-in-the-loop fallback when you cannot reliably locate the target element after approximately 3 attempts using chrome_read_page combined with chrome_click_element/chrome_fill_or_select/chrome_computer. The user will see a panel with instructions and can click on the requested elements. Returns element refs compatible with chrome_click_element/chrome_fill_or_select (including iframe frameId for cross-frame support).',
inputSchema: {
type: 'object',
properties: {
requests: {
type: 'array',
description:
'A list of element selection requests. Each request produces exactly one picked element. The user will see these requests in a panel and select each element by clicking on the page.',
minItems: 1,
items: {
type: 'object',
properties: {
id: {
type: 'string',
description:
'Optional stable request id for correlation. If omitted, an id is auto-generated (e.g., "req_1").',
},
name: {
type: 'string',
description:
'Short label shown to the user describing what element to select (e.g., "Login button", "Email input field").',
},
description: {
type: 'string',
description:
'Optional longer instruction shown to the user with more context (e.g., "Click on the primary login button in the top-right corner").',
},
},
required: ['name'],
},
},
timeoutMs: {
type: 'number',
description:
'Timeout in milliseconds for the user to complete all selections. Default: 180000 (3 minutes). Maximum: 600000 (10 minutes).',
},
tabId: {
type: 'number',
description: 'Target tab ID. If omitted, uses the current active tab.',
},
windowId: {
type: 'number',
description: 'Window ID to select active tab from (when tabId is omitted).',
},
},
required: ['requests'],
},
},
{
name: TOOL_NAMES.BROWSER.KEYBOARD,
description:
+137
View File
@@ -26,3 +26,140 @@ export interface NativeMessage<P = any, E = any> {
payload?: P;
error?: E;
}
// ============================================================
// Element Picker Types (chrome_request_element_selection)
// ============================================================
/**
* A single element selection request from the AI.
*/
export interface ElementPickerRequest {
/**
* Optional stable request id. If omitted, the extension will generate one.
*/
id?: string;
/**
* Short label shown to the user (e.g., "Login button").
*/
name: string;
/**
* Optional longer instruction shown to the user.
*/
description?: string;
}
/**
* Bounding rectangle of a picked element.
*/
export interface PickedElementRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* Center point of a picked element.
*/
export interface PickedElementPoint {
x: number;
y: number;
}
/**
* A picked element that can be used with other tools (click, fill, etc.).
*/
export interface PickedElement {
/**
* Element ref written into window.__claudeElementMap (frame-local).
* Can be used directly with chrome_click_element, chrome_fill_or_select, etc.
*/
ref: string;
/**
* Best-effort stable CSS selector.
*/
selector: string;
/**
* Selector type (currently CSS only).
*/
selectorType: 'css';
/**
* Bounding rect in the element's frame viewport coordinates.
*/
rect: PickedElementRect;
/**
* Center point in the element's frame viewport coordinates.
* Can be used as coordinates for chrome_computer.
*/
center: PickedElementPoint;
/**
* Optional text snippet to help verify the selection.
*/
text?: string;
/**
* Lowercased tag name.
*/
tagName?: string;
/**
* Chrome frameId for iframe targeting.
* Pass this to chrome_click_element/chrome_fill_or_select for cross-frame support.
*/
frameId: number;
}
/**
* Result for a single element selection request.
*/
export interface ElementPickerResultItem {
/**
* The request id (matches the input request).
*/
id: string;
/**
* The request name (for reference).
*/
name: string;
/**
* The picked element, or null if not selected.
*/
element: PickedElement | null;
/**
* Error message if selection failed for this request.
*/
error?: string;
}
/**
* Result of the chrome_request_element_selection tool.
*/
export interface ElementPickerResult {
/**
* True if the user confirmed all selections.
*/
success: boolean;
/**
* Session identifier for this picker session.
*/
sessionId: string;
/**
* Timeout value used for this session.
*/
timeoutMs: number;
/**
* True if the user cancelled the selection.
*/
cancelled?: boolean;
/**
* True if the selection timed out.
*/
timedOut?: boolean;
/**
* List of request IDs that were not selected (for debugging).
*/
missingRequestIds?: string[];
/**
* Results for each requested element.
*/
results: ElementPickerResultItem[];
}