This is page 5 of 43. Use http://codebase.md/hangwin/mcp-chrome?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── common
│ │ │ ├── agent-models.ts
│ │ │ ├── constants.ts
│ │ │ ├── element-marker-types.ts
│ │ │ ├── message-types.ts
│ │ │ ├── node-types.ts
│ │ │ ├── rr-v3-keepalive-protocol.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tool-handler.ts
│ │ │ └── web-editor-types.ts
│ │ ├── entrypoints
│ │ │ ├── background
│ │ │ │ ├── element-marker
│ │ │ │ │ ├── element-marker-storage.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── keepalive-manager.ts
│ │ │ │ ├── native-host.ts
│ │ │ │ ├── quick-panel
│ │ │ │ │ ├── agent-handler.ts
│ │ │ │ │ ├── commands.ts
│ │ │ │ │ └── tabs-handler.ts
│ │ │ │ ├── record-replay
│ │ │ │ │ ├── actions
│ │ │ │ │ │ ├── adapter.ts
│ │ │ │ │ │ ├── handlers
│ │ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ │ ├── control-flow.ts
│ │ │ │ │ │ │ ├── delay.ts
│ │ │ │ │ │ │ ├── dom.ts
│ │ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── constants.ts
│ │ │ │ │ │ ├── execution-mode.ts
│ │ │ │ │ │ ├── logging
│ │ │ │ │ │ │ └── run-logger.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── breakpoint.ts
│ │ │ │ │ │ │ ├── manager.ts
│ │ │ │ │ │ │ └── types.ts
│ │ │ │ │ │ ├── policies
│ │ │ │ │ │ │ ├── retry.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── runners
│ │ │ │ │ │ │ ├── after-script-queue.ts
│ │ │ │ │ │ │ ├── control-flow-runner.ts
│ │ │ │ │ │ │ ├── step-executor.ts
│ │ │ │ │ │ │ ├── step-runner.ts
│ │ │ │ │ │ │ └── subflow-runner.ts
│ │ │ │ │ │ ├── scheduler.ts
│ │ │ │ │ │ ├── state-manager.ts
│ │ │ │ │ │ └── utils
│ │ │ │ │ │ └── expression.ts
│ │ │ │ │ ├── flow-runner.ts
│ │ │ │ │ ├── flow-store.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── legacy-types.ts
│ │ │ │ │ ├── nodes
│ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ ├── conditional.ts
│ │ │ │ │ │ ├── download-screenshot-attr-event-frame-loop.ts
│ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ ├── execute-flow.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ ├── loops.ts
│ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ └── wait.ts
│ │ │ │ │ ├── recording
│ │ │ │ │ │ ├── browser-event-listener.ts
│ │ │ │ │ │ ├── content-injection.ts
│ │ │ │ │ │ ├── content-message-handler.ts
│ │ │ │ │ │ ├── flow-builder.ts
│ │ │ │ │ │ ├── recorder-manager.ts
│ │ │ │ │ │ └── session-manager.ts
│ │ │ │ │ ├── rr-utils.ts
│ │ │ │ │ ├── selector-engine.ts
│ │ │ │ │ ├── storage
│ │ │ │ │ │ └── indexeddb-manager.ts
│ │ │ │ │ ├── trigger-store.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── record-replay-v3
│ │ │ │ │ ├── bootstrap.ts
│ │ │ │ │ ├── domain
│ │ │ │ │ │ ├── debug.ts
│ │ │ │ │ │ ├── errors.ts
│ │ │ │ │ │ ├── events.ts
│ │ │ │ │ │ ├── flow.ts
│ │ │ │ │ │ ├── ids.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── json.ts
│ │ │ │ │ │ ├── policy.ts
│ │ │ │ │ │ ├── triggers.ts
│ │ │ │ │ │ └── variables.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── keepalive
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── offscreen-keepalive.ts
│ │ │ │ │ │ ├── kernel
│ │ │ │ │ │ │ ├── artifacts.ts
│ │ │ │ │ │ │ ├── breakpoints.ts
│ │ │ │ │ │ │ ├── debug-controller.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── kernel.ts
│ │ │ │ │ │ │ ├── recovery-kernel.ts
│ │ │ │ │ │ │ ├── runner.ts
│ │ │ │ │ │ │ └── traversal.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── register-v2-replay-nodes.ts
│ │ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ │ └── v2-action-adapter.ts
│ │ │ │ │ │ ├── queue
│ │ │ │ │ │ │ ├── enqueue-run.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── leasing.ts
│ │ │ │ │ │ │ ├── queue.ts
│ │ │ │ │ │ │ └── scheduler.ts
│ │ │ │ │ │ ├── recovery
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── recovery-coordinator.ts
│ │ │ │ │ │ ├── storage
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── storage-port.ts
│ │ │ │ │ │ ├── transport
│ │ │ │ │ │ │ ├── events-bus.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── rpc-server.ts
│ │ │ │ │ │ │ └── rpc.ts
│ │ │ │ │ │ └── triggers
│ │ │ │ │ │ ├── command-trigger.ts
│ │ │ │ │ │ ├── context-menu-trigger.ts
│ │ │ │ │ │ ├── cron-trigger.ts
│ │ │ │ │ │ ├── dom-trigger.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── interval-trigger.ts
│ │ │ │ │ │ ├── manual-trigger.ts
│ │ │ │ │ │ ├── once-trigger.ts
│ │ │ │ │ │ ├── trigger-handler.ts
│ │ │ │ │ │ ├── trigger-manager.ts
│ │ │ │ │ │ └── url-trigger.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── storage
│ │ │ │ │ ├── db.ts
│ │ │ │ │ ├── events.ts
│ │ │ │ │ ├── flows.ts
│ │ │ │ │ ├── import
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── v2-reader.ts
│ │ │ │ │ │ └── v2-to-v3.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── persistent-vars.ts
│ │ │ │ │ ├── queue.ts
│ │ │ │ │ ├── runs.ts
│ │ │ │ │ └── triggers.ts
│ │ │ │ ├── semantic-similarity.ts
│ │ │ │ ├── storage-manager.ts
│ │ │ │ ├── tools
│ │ │ │ │ ├── base-browser.ts
│ │ │ │ │ ├── browser
│ │ │ │ │ │ ├── bookmark.ts
│ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ ├── computer.ts
│ │ │ │ │ │ ├── console-buffer.ts
│ │ │ │ │ │ ├── console.ts
│ │ │ │ │ │ ├── dialog.ts
│ │ │ │ │ │ ├── download.ts
│ │ │ │ │ │ ├── element-picker.ts
│ │ │ │ │ │ ├── file-upload.ts
│ │ │ │ │ │ ├── gif-auto-capture.ts
│ │ │ │ │ │ ├── gif-enhanced-renderer.ts
│ │ │ │ │ │ ├── gif-recorder.ts
│ │ │ │ │ │ ├── history.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── inject-script.ts
│ │ │ │ │ │ ├── interaction.ts
│ │ │ │ │ │ ├── javascript.ts
│ │ │ │ │ │ ├── keyboard.ts
│ │ │ │ │ │ ├── network-capture-debugger.ts
│ │ │ │ │ │ ├── network-capture-web-request.ts
│ │ │ │ │ │ ├── network-capture.ts
│ │ │ │ │ │ ├── network-request.ts
│ │ │ │ │ │ ├── performance.ts
│ │ │ │ │ │ ├── read-page.ts
│ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ ├── userscript.ts
│ │ │ │ │ │ ├── vector-search.ts
│ │ │ │ │ │ ├── web-fetcher.ts
│ │ │ │ │ │ └── window.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── record-replay.ts
│ │ │ │ ├── utils
│ │ │ │ │ └── sidepanel.ts
│ │ │ │ └── web-editor
│ │ │ │ └── index.ts
│ │ │ ├── builder
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── content.ts
│ │ │ ├── element-picker.content.ts
│ │ │ ├── offscreen
│ │ │ │ ├── gif-encoder.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── rr-keepalive.ts
│ │ │ ├── options
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── popup
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ │ ├── node-util.ts
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ └── NodeIf.vue
│ │ │ │ │ │ │ ├── properties
│ │ │ │ │ │ │ │ ├── PropertyAssert.vue
│ │ │ │ │ │ │ │ ├── PropertyClick.vue
│ │ │ │ │ │ │ │ ├── PropertyCloseTab.vue
│ │ │ │ │ │ │ │ ├── PropertyDelay.vue
│ │ │ │ │ │ │ │ ├── PropertyDrag.vue
│ │ │ │ │ │ │ │ ├── PropertyExecuteFlow.vue
│ │ │ │ │ │ │ │ ├── PropertyExtract.vue
│ │ │ │ │ │ │ │ ├── PropertyFill.vue
│ │ │ │ │ │ │ │ ├── PropertyForeach.vue
│ │ │ │ │ │ │ │ ├── PropertyFormRenderer.vue
│ │ │ │ │ │ │ │ ├── PropertyFromSpec.vue
│ │ │ │ │ │ │ │ ├── PropertyHandleDownload.vue
│ │ │ │ │ │ │ │ ├── PropertyHttp.vue
│ │ │ │ │ │ │ │ ├── PropertyIf.vue
│ │ │ │ │ │ │ │ ├── PropertyKey.vue
│ │ │ │ │ │ │ │ ├── PropertyLoopElements.vue
│ │ │ │ │ │ │ │ ├── PropertyNavigate.vue
│ │ │ │ │ │ │ │ ├── PropertyOpenTab.vue
│ │ │ │ │ │ │ │ ├── PropertyScreenshot.vue
│ │ │ │ │ │ │ │ ├── PropertyScript.vue
│ │ │ │ │ │ │ │ ├── PropertyScroll.vue
│ │ │ │ │ │ │ │ ├── PropertySetAttribute.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchFrame.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchTab.vue
│ │ │ │ │ │ │ │ ├── PropertyTrigger.vue
│ │ │ │ │ │ │ │ ├── PropertyTriggerEvent.vue
│ │ │ │ │ │ │ │ ├── PropertyWait.vue
│ │ │ │ │ │ │ │ ├── PropertyWhile.vue
│ │ │ │ │ │ │ │ └── SelectorEditor.vue
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ └── TriggerPanel.vue
│ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ ├── form-widget-registry.ts
│ │ │ │ │ │ │ ├── node-spec-registry.ts
│ │ │ │ │ │ │ ├── node-spec.ts
│ │ │ │ │ │ │ ├── node-specs-builtin.ts
│ │ │ │ │ │ │ ├── toast.ts
│ │ │ │ │ │ │ ├── transforms.ts
│ │ │ │ │ │ │ ├── ui-nodes.ts
│ │ │ │ │ │ │ ├── validation.ts
│ │ │ │ │ │ │ └── variables.ts
│ │ │ │ │ │ ├── store
│ │ │ │ │ │ │ └── useBuilderStore.ts
│ │ │ │ │ │ └── widgets
│ │ │ │ │ │ ├── FieldCode.vue
│ │ │ │ │ │ ├── FieldDuration.vue
│ │ │ │ │ │ ├── FieldExpression.vue
│ │ │ │ │ │ ├── FieldKeySequence.vue
│ │ │ │ │ │ ├── FieldSelector.vue
│ │ │ │ │ │ ├── FieldTargetLocator.vue
│ │ │ │ │ │ └── VarInput.vue
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── icons
│ │ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ │ └── WorkflowIcon.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ └── ScheduleDialog.vue
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── style.css
│ │ │ ├── quick-panel.content.ts
│ │ │ ├── shared
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── useRRV3Rpc.ts
│ │ │ │ └── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── rr-flow-convert.ts
│ │ │ ├── sidepanel
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── agent
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ └── ProjectSelector.vue
│ │ │ │ │ ├── agent-chat
│ │ │ │ │ │ ├── AgentChatShell.vue
│ │ │ │ │ │ ├── AgentComposer.vue
│ │ │ │ │ │ ├── AgentConversation.vue
│ │ │ │ │ │ ├── AgentOpenProjectMenu.vue
│ │ │ │ │ │ ├── AgentProjectMenu.vue
│ │ │ │ │ │ ├── AgentRequestThread.vue
│ │ │ │ │ │ ├── AgentSessionListItem.vue
│ │ │ │ │ │ ├── AgentSessionMenu.vue
│ │ │ │ │ │ ├── AgentSessionSettingsPanel.vue
│ │ │ │ │ │ ├── AgentSessionsView.vue
│ │ │ │ │ │ ├── AgentSettingsMenu.vue
│ │ │ │ │ │ ├── AgentTimeline.vue
│ │ │ │ │ │ ├── AgentTimelineItem.vue
│ │ │ │ │ │ ├── AgentTopBar.vue
│ │ │ │ │ │ ├── ApplyMessageChip.vue
│ │ │ │ │ │ ├── AttachmentCachePanel.vue
│ │ │ │ │ │ ├── ComposerDrawer.vue
│ │ │ │ │ │ ├── ElementChip.vue
│ │ │ │ │ │ ├── FakeCaretOverlay.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── timeline
│ │ │ │ │ │ │ ├── markstream-thinking.ts
│ │ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ │ └── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── WebEditorChanges.vue
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── rr-v3
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ └── workflows
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ └── WorkflowsView.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── useAgentChat.ts
│ │ │ │ │ ├── useAgentChatViewRoute.ts
│ │ │ │ │ ├── useAgentInputPreferences.ts
│ │ │ │ │ ├── useAgentProjects.ts
│ │ │ │ │ ├── useAgentServer.ts
│ │ │ │ │ ├── useAgentSessions.ts
│ │ │ │ │ ├── useAgentTheme.ts
│ │ │ │ │ ├── useAgentThreads.ts
│ │ │ │ │ ├── useAttachments.ts
│ │ │ │ │ ├── useFakeCaret.ts
│ │ │ │ │ ├── useFloatingDrag.ts
│ │ │ │ │ ├── useOpenProjectPreference.ts
│ │ │ │ │ ├── useRRV3Debugger.ts
│ │ │ │ │ ├── useRRV3Rpc.ts
│ │ │ │ │ ├── useTextareaAutoResize.ts
│ │ │ │ │ ├── useWebEditorTxState.ts
│ │ │ │ │ └── useWorkflowsV3.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ ├── styles
│ │ │ │ │ └── agent-chat.css
│ │ │ │ └── utils
│ │ │ │ └── loading-texts.ts
│ │ │ ├── styles
│ │ │ │ └── tailwind.css
│ │ │ ├── web-editor-v2
│ │ │ │ ├── attr-ui-refactor.md
│ │ │ │ ├── constants.ts
│ │ │ │ ├── core
│ │ │ │ │ ├── css-compare.ts
│ │ │ │ │ ├── cssom-styles-collector.ts
│ │ │ │ │ ├── debug-source.ts
│ │ │ │ │ ├── design-tokens
│ │ │ │ │ │ ├── design-tokens-service.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── token-detector.ts
│ │ │ │ │ │ ├── token-resolver.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── editor.ts
│ │ │ │ │ ├── element-key.ts
│ │ │ │ │ ├── event-controller.ts
│ │ │ │ │ ├── execution-tracker.ts
│ │ │ │ │ ├── hmr-consistency.ts
│ │ │ │ │ ├── locator.ts
│ │ │ │ │ ├── message-listener.ts
│ │ │ │ │ ├── payload-builder.ts
│ │ │ │ │ ├── perf-monitor.ts
│ │ │ │ │ ├── position-tracker.ts
│ │ │ │ │ ├── props-bridge.ts
│ │ │ │ │ ├── snap-engine.ts
│ │ │ │ │ ├── transaction-aggregator.ts
│ │ │ │ │ └── transaction-manager.ts
│ │ │ │ ├── drag
│ │ │ │ │ └── drag-reorder-controller.ts
│ │ │ │ ├── overlay
│ │ │ │ │ ├── canvas-overlay.ts
│ │ │ │ │ └── handles-controller.ts
│ │ │ │ ├── selection
│ │ │ │ │ └── selection-engine.ts
│ │ │ │ ├── ui
│ │ │ │ │ ├── breadcrumbs.ts
│ │ │ │ │ ├── floating-drag.ts
│ │ │ │ │ ├── icons.ts
│ │ │ │ │ ├── property-panel
│ │ │ │ │ │ ├── class-editor.ts
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── alignment-grid.ts
│ │ │ │ │ │ │ ├── icon-button-group.ts
│ │ │ │ │ │ │ ├── input-container.ts
│ │ │ │ │ │ │ ├── slider-input.ts
│ │ │ │ │ │ │ └── token-pill.ts
│ │ │ │ │ │ ├── components-tree.ts
│ │ │ │ │ │ ├── controls
│ │ │ │ │ │ │ ├── appearance-control.ts
│ │ │ │ │ │ │ ├── background-control.ts
│ │ │ │ │ │ │ ├── border-control.ts
│ │ │ │ │ │ │ ├── color-field.ts
│ │ │ │ │ │ │ ├── css-helpers.ts
│ │ │ │ │ │ │ ├── effects-control.ts
│ │ │ │ │ │ │ ├── gradient-control.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── layout-control.ts
│ │ │ │ │ │ │ ├── number-stepping.ts
│ │ │ │ │ │ │ ├── position-control.ts
│ │ │ │ │ │ │ ├── size-control.ts
│ │ │ │ │ │ │ ├── spacing-control.ts
│ │ │ │ │ │ │ ├── token-picker.ts
│ │ │ │ │ │ │ └── typography-control.ts
│ │ │ │ │ │ ├── css-defaults.ts
│ │ │ │ │ │ ├── css-panel.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── property-panel.ts
│ │ │ │ │ │ ├── props-panel.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── shadow-host.ts
│ │ │ │ │ └── toolbar.ts
│ │ │ │ └── utils
│ │ │ │ └── disposables.ts
│ │ │ ├── web-editor-v2.ts
│ │ │ └── welcome
│ │ │ ├── App.vue
│ │ │ ├── index.html
│ │ │ └── main.ts
│ │ ├── env.d.ts
│ │ ├── eslint.config.js
│ │ ├── inject-scripts
│ │ │ ├── accessibility-tree-helper.js
│ │ │ ├── click-helper.js
│ │ │ ├── dom-observer.js
│ │ │ ├── element-marker.js
│ │ │ ├── element-picker.js
│ │ │ ├── fill-helper.js
│ │ │ ├── inject-bridge.js
│ │ │ ├── interactive-elements-helper.js
│ │ │ ├── keyboard-helper.js
│ │ │ ├── network-helper.js
│ │ │ ├── props-agent.js
│ │ │ ├── recorder.js
│ │ │ ├── screenshot-helper.js
│ │ │ ├── wait-helper.js
│ │ │ ├── web-editor.js
│ │ │ └── web-fetcher-helper.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── shared
│ │ │ ├── element-picker
│ │ │ │ ├── controller.ts
│ │ │ │ └── index.ts
│ │ │ ├── quick-panel
│ │ │ │ ├── core
│ │ │ │ │ ├── agent-bridge.ts
│ │ │ │ │ ├── search-engine.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── providers
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── tabs-provider.ts
│ │ │ │ └── ui
│ │ │ │ ├── ai-chat-panel.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── message-renderer.ts
│ │ │ │ ├── panel-shell.ts
│ │ │ │ ├── quick-entries.ts
│ │ │ │ ├── search-input.ts
│ │ │ │ ├── shadow-host.ts
│ │ │ │ └── styles.ts
│ │ │ └── selector
│ │ │ ├── dom-path.ts
│ │ │ ├── fingerprint.ts
│ │ │ ├── generator.ts
│ │ │ ├── index.ts
│ │ │ ├── locator.ts
│ │ │ ├── shadow-dom.ts
│ │ │ ├── stability.ts
│ │ │ ├── strategies
│ │ │ │ ├── anchor-relpath.ts
│ │ │ │ ├── aria.ts
│ │ │ │ ├── css-path.ts
│ │ │ │ ├── css-unique.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── testid.ts
│ │ │ │ └── text.ts
│ │ │ └── types.ts
│ │ ├── tailwind.config.ts
│ │ ├── tests
│ │ │ ├── __mocks__
│ │ │ │ └── hnswlib-wasm-static.ts
│ │ │ ├── record-replay
│ │ │ │ ├── _test-helpers.ts
│ │ │ │ ├── adapter-policy.contract.test.ts
│ │ │ │ ├── flow-store-strip-steps.contract.test.ts
│ │ │ │ ├── high-risk-actions.integration.test.ts
│ │ │ │ ├── hybrid-actions.integration.test.ts
│ │ │ │ ├── script-control-flow.integration.test.ts
│ │ │ │ ├── session-dag-sync.contract.test.ts
│ │ │ │ ├── step-executor.contract.test.ts
│ │ │ │ └── tab-cursor.integration.test.ts
│ │ │ ├── record-replay-v3
│ │ │ │ ├── command-trigger.test.ts
│ │ │ │ ├── context-menu-trigger.test.ts
│ │ │ │ ├── cron-trigger.test.ts
│ │ │ │ ├── debugger.contract.test.ts
│ │ │ │ ├── dom-trigger.test.ts
│ │ │ │ ├── e2e.integration.test.ts
│ │ │ │ ├── events.contract.test.ts
│ │ │ │ ├── interval-trigger.test.ts
│ │ │ │ ├── manual-trigger.test.ts
│ │ │ │ ├── once-trigger.test.ts
│ │ │ │ ├── queue.contract.test.ts
│ │ │ │ ├── recovery.test.ts
│ │ │ │ ├── rpc-api.test.ts
│ │ │ │ ├── runner.onError.contract.test.ts
│ │ │ │ ├── scheduler-integration.test.ts
│ │ │ │ ├── scheduler.test.ts
│ │ │ │ ├── spec-smoke.test.ts
│ │ │ │ ├── trigger-manager.test.ts
│ │ │ │ ├── triggers.test.ts
│ │ │ │ ├── url-trigger.test.ts
│ │ │ │ ├── v2-action-adapter.test.ts
│ │ │ │ ├── v2-adapter-integration.test.ts
│ │ │ │ ├── v2-to-v3-conversion.test.ts
│ │ │ │ └── v3-e2e-harness.ts
│ │ │ ├── vitest.setup.ts
│ │ │ └── web-editor-v2
│ │ │ ├── design-tokens.test.ts
│ │ │ ├── drag-reorder-controller.test.ts
│ │ │ ├── event-controller.test.ts
│ │ │ ├── locator.test.ts
│ │ │ ├── property-panel-live-sync.test.ts
│ │ │ ├── selection-engine.test.ts
│ │ │ ├── snap-engine.test.ts
│ │ │ └── test-utils
│ │ │ └── dom.ts
│ │ ├── tsconfig.json
│ │ ├── types
│ │ │ ├── gifenc.d.ts
│ │ │ └── icons.d.ts
│ │ ├── utils
│ │ │ ├── cdp-session-manager.ts
│ │ │ ├── content-indexer.ts
│ │ │ ├── i18n.ts
│ │ │ ├── image-utils.ts
│ │ │ ├── indexeddb-client.ts
│ │ │ ├── lru-cache.ts
│ │ │ ├── model-cache-manager.ts
│ │ │ ├── offscreen-manager.ts
│ │ │ ├── output-sanitizer.ts
│ │ │ ├── screenshot-context.ts
│ │ │ ├── semantic-similarity-engine.ts
│ │ │ ├── simd-math-engine.ts
│ │ │ ├── text-chunker.ts
│ │ │ └── vector-database.ts
│ │ ├── vitest.config.ts
│ │ ├── workers
│ │ │ ├── ort-wasm-simd-threaded.jsep.mjs
│ │ │ ├── ort-wasm-simd-threaded.jsep.wasm
│ │ │ ├── ort-wasm-simd-threaded.mjs
│ │ │ ├── ort-wasm-simd-threaded.wasm
│ │ │ ├── simd_math_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── .npmignore
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── agent
│ │ │ ├── attachment-service.ts
│ │ │ ├── ccr-detector.ts
│ │ │ ├── chat-service.ts
│ │ │ ├── db
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── directory-picker.ts
│ │ │ ├── engines
│ │ │ │ ├── claude.ts
│ │ │ │ ├── codex.ts
│ │ │ │ └── types.ts
│ │ │ ├── message-service.ts
│ │ │ ├── open-project.ts
│ │ │ ├── project-service.ts
│ │ │ ├── project-types.ts
│ │ │ ├── session-service.ts
│ │ │ ├── storage.ts
│ │ │ ├── stream-manager.ts
│ │ │ ├── tool-bridge.ts
│ │ │ └── types.ts
│ │ ├── cli.ts
│ │ ├── constant
│ │ │ └── index.ts
│ │ ├── file-handler.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── mcp-server-stdio.ts
│ │ │ ├── mcp-server.ts
│ │ │ ├── register-tools.ts
│ │ │ └── stdio-config.json
│ │ ├── native-messaging-host.ts
│ │ ├── scripts
│ │ │ ├── browser-config.ts
│ │ │ ├── build.ts
│ │ │ ├── constant.ts
│ │ │ ├── doctor.ts
│ │ │ ├── postinstall.ts
│ │ │ ├── register-dev.ts
│ │ │ ├── register.ts
│ │ │ ├── report.ts
│ │ │ ├── run_host.bat
│ │ │ ├── run_host.sh
│ │ │ └── utils.ts
│ │ ├── server
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── agent.ts
│ │ │ │ └── index.ts
│ │ │ └── server.test.ts
│ │ ├── shims
│ │ │ └── devtools.d.ts
│ │ ├── trace-analyzer.ts
│ │ ├── types
│ │ │ └── devtools-frontend.d.ts
│ │ └── util
│ │ └── logger.ts
│ └── tsconfig.json
├── commitlint.config.cjs
├── docs
│ ├── ARCHITECTURE_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE.md
│ ├── mcp-cli-config.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── VisualEditor_zh.md
│ ├── VisualEditor.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── agent-types.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── labels.ts
│ │ │ ├── node-spec-registry.ts
│ │ │ ├── node-spec.ts
│ │ │ ├── node-specs-builtin.ts
│ │ │ ├── rr-graph.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tools.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ └── wasm-simd
│ ├── .gitignore
│ ├── BUILD.md
│ ├── Cargo.toml
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
└── releases
├── chrome-extension
│ └── latest
│ └── chrome-mcp-server-lastest.zip
└── README.md
```
# Files
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview V3 IndexedDB 数据库定义
* @description 定义 rr_v3 数据库的 schema 和初始化逻辑
*/
/** 数据库名称 */
export const RR_V3_DB_NAME = 'rr_v3';
/** 数据库版本 */
export const RR_V3_DB_VERSION = 1;
/**
* Store 名称常量
*/
export const RR_V3_STORES = {
FLOWS: 'flows',
RUNS: 'runs',
EVENTS: 'events',
QUEUE: 'queue',
PERSISTENT_VARS: 'persistent_vars',
TRIGGERS: 'triggers',
} as const;
/**
* Store 配置
*/
export interface StoreConfig {
keyPath: string | string[];
autoIncrement?: boolean;
indexes?: Array<{
name: string;
keyPath: string | string[];
options?: IDBIndexParameters;
}>;
}
/**
* V3 Store Schema 定义
* @description 包含 Phase 1-3 所需的所有索引,避免后续升级
*/
export const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {
[RR_V3_STORES.FLOWS]: {
keyPath: 'id',
indexes: [
{ name: 'name', keyPath: 'name' },
{ name: 'updatedAt', keyPath: 'updatedAt' },
],
},
[RR_V3_STORES.RUNS]: {
keyPath: 'id',
indexes: [
{ name: 'status', keyPath: 'status' },
{ name: 'flowId', keyPath: 'flowId' },
{ name: 'createdAt', keyPath: 'createdAt' },
{ name: 'updatedAt', keyPath: 'updatedAt' },
// Compound index for listing runs by flow and status
{ name: 'flowId_status', keyPath: ['flowId', 'status'] },
],
},
[RR_V3_STORES.EVENTS]: {
keyPath: ['runId', 'seq'],
indexes: [
{ name: 'runId', keyPath: 'runId' },
{ name: 'type', keyPath: 'type' },
// Compound index for filtering events by run and type
{ name: 'runId_type', keyPath: ['runId', 'type'] },
],
},
[RR_V3_STORES.QUEUE]: {
keyPath: 'id',
indexes: [
{ name: 'status', keyPath: 'status' },
{ name: 'priority', keyPath: 'priority' },
{ name: 'createdAt', keyPath: 'createdAt' },
{ name: 'flowId', keyPath: 'flowId' },
// Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.
{ name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },
// Phase 3: Lease expiration tracking
{ name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },
],
},
[RR_V3_STORES.PERSISTENT_VARS]: {
keyPath: 'key',
indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],
},
[RR_V3_STORES.TRIGGERS]: {
keyPath: 'id',
indexes: [
{ name: 'kind', keyPath: 'kind' },
{ name: 'flowId', keyPath: 'flowId' },
{ name: 'enabled', keyPath: 'enabled' },
// Compound index for listing enabled triggers by kind
{ name: 'kind_enabled', keyPath: ['kind', 'enabled'] },
],
},
};
/**
* 数据库升级处理器
*/
export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {
// Version 0 -> 1: 创建所有 stores
if (oldVersion < 1) {
for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {
const store = db.createObjectStore(storeName, {
keyPath: config.keyPath,
autoIncrement: config.autoIncrement,
});
// 创建索引
if (config.indexes) {
for (const index of config.indexes) {
store.createIndex(index.name, index.keyPath, index.options);
}
}
}
}
}
/** 全局数据库实例 */
let dbInstance: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null;
/**
* 打开 V3 数据库
* @description 单例模式,确保只有一个数据库连接
*/
export async function openRrV3Db(): Promise<IDBDatabase> {
if (dbInstance) {
return dbInstance;
}
if (dbPromise) {
return dbPromise;
}
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);
request.onerror = () => {
dbPromise = null;
reject(new Error(`Failed to open database: ${request.error?.message}`));
};
request.onsuccess = () => {
dbInstance = request.result;
// 处理版本变更(其他 tab 升级了数据库)
dbInstance.onversionchange = () => {
dbInstance?.close();
dbInstance = null;
dbPromise = null;
};
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = event.oldVersion;
const newVersion = event.newVersion ?? RR_V3_DB_VERSION;
handleUpgrade(db, oldVersion, newVersion);
};
});
return dbPromise;
}
/**
* 关闭数据库连接
* @description 主要用于测试
*/
export function closeRrV3Db(): void {
if (dbInstance) {
dbInstance.close();
dbInstance = null;
dbPromise = null;
}
}
/**
* 删除数据库
* @description 主要用于测试
*/
export async function deleteRrV3Db(): Promise<void> {
closeRrV3Db();
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 执行事务
* @param storeNames Store 名称(单个或多个)
* @param mode 事务模式
* @param callback 事务回调
*/
export async function withTransaction<T>(
storeNames: string | string[],
mode: IDBTransactionMode,
callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,
): Promise<T> {
const db = await openRrV3Db();
const names = Array.isArray(storeNames) ? storeNames : [storeNames];
const tx = db.transaction(names, mode);
const stores: Record<string, IDBObjectStore> = {};
for (const name of names) {
stores[name] = tx.objectStore(name);
}
return new Promise<T>((resolve, reject) => {
let result: T;
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
Promise.resolve(callback(stores))
.then((r) => {
result = r;
})
.catch((err) => {
tx.abort();
reject(err);
});
});
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/stability.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Selector Stability - 选择器稳定性评估
*/
import type {
SelectorCandidate,
SelectorStability,
SelectorStabilitySignals,
SelectorType,
} from './types';
import { splitCompositeSelector } from './types';
const TESTID_ATTR_NAMES = [
'data-testid',
'data-test-id',
'data-test',
'data-qa',
'data-cy',
] as const;
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.min(1, Math.max(0, n));
}
function mergeSignals(
a: SelectorStabilitySignals,
b: SelectorStabilitySignals,
): SelectorStabilitySignals {
return {
usesId: a.usesId || b.usesId || undefined,
usesTestId: a.usesTestId || b.usesTestId || undefined,
usesAria: a.usesAria || b.usesAria || undefined,
usesText: a.usesText || b.usesText || undefined,
usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,
usesAttributes: a.usesAttributes || b.usesAttributes || undefined,
usesClass: a.usesClass || b.usesClass || undefined,
};
}
function analyzeCssLike(selector: string): SelectorStabilitySignals {
const s = String(selector || '');
const usesNthOfType = /:nth-of-type\(/i.test(s);
const usesAttributes = /\[[^\]]+\]/.test(s);
const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s);
// Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern.
const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s);
const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s);
const lower = s.toLowerCase();
const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));
return {
usesId: usesId || undefined,
usesTestId: usesTestId || undefined,
usesAria: usesAria || undefined,
usesNthOfType: usesNthOfType || undefined,
usesAttributes: usesAttributes || undefined,
usesClass: usesClass || undefined,
};
}
function baseScoreForCssSignals(signals: SelectorStabilitySignals): number {
if (signals.usesTestId) return 0.95;
if (signals.usesId) return 0.9;
if (signals.usesAria) return 0.8;
if (signals.usesAttributes) return 0.75;
if (signals.usesClass) return 0.65;
return 0.5;
}
function lengthPenalty(value: string): number {
const len = value.length;
if (len <= 60) return 0;
if (len <= 120) return 0.05;
if (len <= 200) return 0.1;
return 0.18;
}
/**
* 计算选择器稳定性评分
*/
export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {
if (candidate.type === 'css' || candidate.type === 'attr') {
const composite = splitCompositeSelector(candidate.value);
if (composite) {
const a = analyzeCssLike(composite.frameSelector);
const b = analyzeCssLike(composite.innerSelector);
const merged = mergeSignals(a, b);
let score = baseScoreForCssSignals(merged);
score -= 0.05; // iframe coupling penalty
if (merged.usesNthOfType) score -= 0.2;
score -= lengthPenalty(candidate.value);
return { score: clamp01(score), signals: merged, note: 'composite' };
}
const signals = analyzeCssLike(candidate.value);
let score = baseScoreForCssSignals(signals);
if (signals.usesNthOfType) score -= 0.2;
score -= lengthPenalty(candidate.value);
return { score: clamp01(score), signals };
}
if (candidate.type === 'xpath') {
const s = String(candidate.value || '');
const signals: SelectorStabilitySignals = {
usesAttributes: /@[\w-]+\s*=/.test(s) || undefined,
usesId: /@id\s*=/.test(s) || undefined,
usesTestId: /@data-testid\s*=/.test(s) || undefined,
};
let score = 0.42;
if (signals.usesTestId) score = 0.85;
else if (signals.usesId) score = 0.75;
else if (signals.usesAttributes) score = 0.55;
score -= lengthPenalty(s);
return { score: clamp01(score), signals };
}
if (candidate.type === 'aria') {
const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;
const signals: SelectorStabilitySignals = { usesAria: true };
let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;
score -= lengthPenalty(candidate.value);
return { score: clamp01(score), signals };
}
// text
const text = String(candidate.value || '').trim();
const signals: SelectorStabilitySignals = { usesText: true };
let score = 0.35;
// Very short texts tend to be ambiguous; very long texts are unstable.
if (text.length >= 6 && text.length <= 48) score = 0.45;
if (text.length > 80) score = 0.3;
return { score: clamp01(score), signals };
}
/**
* 为选择器候选添加稳定性评分
*/
export function withStability(candidate: SelectorCandidate): SelectorCandidate {
if (candidate.stability) return candidate;
return { ...candidate, stability: computeSelectorStability(candidate) };
}
function typePriority(type: SelectorType): number {
switch (type) {
case 'attr':
return 5;
case 'css':
return 4;
case 'aria':
return 3;
case 'xpath':
return 2;
case 'text':
return 1;
default:
return 0;
}
}
/**
* 比较两个选择器候选的优先级
* 返回负数表示 a 优先,正数表示 b 优先
*/
export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {
// 1. 用户指定的权重优先
const aw = a.weight ?? 0;
const bw = b.weight ?? 0;
if (aw !== bw) return bw - aw;
// 2. 稳定性评分
const as = a.stability?.score ?? computeSelectorStability(a).score;
const bs = b.stability?.score ?? computeSelectorStability(b).score;
if (as !== bs) return bs - as;
// 3. 类型优先级
const ap = typePriority(a.type);
const bp = typePriority(b.type);
if (ap !== bp) return bp - ap;
// 4. 长度(越短越好)
const alen = String(a.value || '').length;
const blen = String(b.value || '').length;
return alen - blen;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/common/constants.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Chrome Extension Constants
* Centralized configuration values and magic constants
*/
// Native Host Configuration
export const NATIVE_HOST = {
NAME: 'com.chromemcp.nativehost',
DEFAULT_PORT: 12306,
} as const;
// Chrome Extension Icons
export const ICONS = {
NOTIFICATION: 'icon/48.png',
} as const;
// Timeouts and Delays (in milliseconds)
export const TIMEOUTS = {
DEFAULT_WAIT: 1000,
NETWORK_CAPTURE_MAX: 30000,
NETWORK_CAPTURE_IDLE: 3000,
SCREENSHOT_DELAY: 100,
KEYBOARD_DELAY: 50,
CLICK_DELAY: 100,
} as const;
// Limits and Thresholds
export const LIMITS = {
MAX_NETWORK_REQUESTS: 100,
MAX_SEARCH_RESULTS: 50,
MAX_BOOKMARK_RESULTS: 100,
MAX_HISTORY_RESULTS: 100,
SIMILARITY_THRESHOLD: 0.1,
VECTOR_DIMENSIONS: 384,
} as const;
// Error Messages
export const ERROR_MESSAGES = {
NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
NATIVE_DISCONNECTED: 'Native connection disconnected',
SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
TOOL_EXECUTION_FAILED: 'Tool execution failed',
INVALID_PARAMETERS: 'Invalid parameters provided',
PERMISSION_DENIED: 'Permission denied',
TAB_NOT_FOUND: 'Tab not found',
ELEMENT_NOT_FOUND: 'Element not found',
NETWORK_ERROR: 'Network error occurred',
} as const;
// Success Messages
export const SUCCESS_MESSAGES = {
TOOL_EXECUTED: 'Tool executed successfully',
CONNECTION_ESTABLISHED: 'Connection established',
SERVER_STARTED: 'Server started successfully',
SERVER_STOPPED: 'Server stopped successfully',
} as const;
// External Links
export const LINKS = {
TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
} as const;
// File Extensions and MIME Types
export const FILE_TYPES = {
STATIC_EXTENSIONS: [
'.css',
'.js',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
'.ico',
'.woff',
'.woff2',
'.ttf',
],
FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
} as const;
// Network Filtering
export const NETWORK_FILTERS = {
// Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
EXCLUDED_DOMAINS: [
// Google
'google-analytics.com',
'googletagmanager.com',
'analytics.google.com',
'doubleclick.net',
'googlesyndication.com',
'googleads.g.doubleclick.net',
'stats.g.doubleclick.net',
'adservice.google.com',
'pagead2.googlesyndication.com',
// Amazon
'amazon-adsystem.com',
// Microsoft
'bat.bing.com',
'clarity.ms',
// Facebook
'connect.facebook.net',
'facebook.com/tr',
// Twitter
'analytics.twitter.com',
'ads-twitter.com',
// Other ad networks
'ads.yahoo.com',
'adroll.com',
'adnxs.com',
'criteo.com',
'quantserve.com',
'scorecardresearch.com',
// Analytics & session recording
'segment.io',
'amplitude.com',
'mixpanel.com',
'optimizely.com',
'static.hotjar.com',
'script.hotjar.com',
'crazyegg.com',
'clicktale.net',
'mouseflow.com',
'fullstory.com',
// LinkedIn (tracking pixels)
'linkedin.com/px',
],
// Static resource extensions (used when includeStatic=false)
STATIC_RESOURCE_EXTENSIONS: [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.svg',
'.webp',
'.ico',
'.bmp',
'.cur',
'.css',
'.scss',
'.less',
'.js',
'.jsx',
'.ts',
'.tsx',
'.map',
'.woff',
'.woff2',
'.ttf',
'.eot',
'.otf',
'.mp3',
'.mp4',
'.avi',
'.mov',
'.wmv',
'.flv',
'.webm',
'.ogg',
'.wav',
'.pdf',
'.zip',
'.rar',
'.7z',
'.iso',
'.dmg',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
],
// MIME types treated as static/binary (filtered when includeStatic=false)
STATIC_MIME_TYPES_TO_FILTER: [
'image/',
'font/',
'audio/',
'video/',
'text/css',
'text/javascript',
'application/javascript',
'application/x-javascript',
'application/pdf',
'application/zip',
'application/octet-stream',
],
// API-like MIME types (never filtered by MIME)
API_MIME_TYPES: [
'application/json',
'application/xml',
'text/xml',
'text/plain',
'text/event-stream',
'application/x-www-form-urlencoded',
'application/graphql',
'application/grpc',
'application/protobuf',
'application/x-protobuf',
'application/x-json',
'application/ld+json',
'application/problem+json',
'application/problem+xml',
'application/soap+xml',
'application/vnd.api+json',
],
STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
} as const;
// Semantic Similarity Configuration
export const SEMANTIC_CONFIG = {
DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
CHUNK_SIZE: 512,
CHUNK_OVERLAP: 50,
BATCH_SIZE: 32,
CACHE_SIZE: 1000,
} as const;
// Storage Keys
export const STORAGE_KEYS = {
SERVER_STATUS: 'serverStatus',
NATIVE_SERVER_PORT: 'nativeServerPort',
NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
SEMANTIC_MODEL: 'selectedModel',
USER_PREFERENCES: 'userPreferences',
VECTOR_INDEX: 'vectorIndex',
USERSCRIPTS: 'userscripts',
USERSCRIPTS_DISABLED: 'userscripts_disabled',
// Record & Replay storage keys
RR_FLOWS: 'rr_flows',
RR_RUNS: 'rr_runs',
RR_PUBLISHED: 'rr_published_flows',
RR_SCHEDULES: 'rr_schedules',
RR_TRIGGERS: 'rr_triggers',
// Persistent recording state (guards resume across navigations/service worker restarts)
RR_RECORDING_STATE: 'rr_recording_state',
} as const;
// Notification Configuration
export const NOTIFICATIONS = {
PRIORITY: 2,
TYPE: 'basic' as const,
} as const;
export enum ExecutionWorld {
ISOLATED = 'ISOLATED',
MAIN = 'MAIN',
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="section">
<h2 class="section-title">元素标注管理</h2>
<div class="config-card">
<div class="status-section" style="gap: 8px">
<div class="status-header">
<p class="status-label">当前页面</p>
<span class="status-text" style="opacity: 0.85">{{ currentUrl }}</span>
</div>
<div class="status-header">
<p class="status-label">已标注元素</p>
<span class="status-text">{{ markers.length }}</span>
</div>
</div>
<form class="mcp-config-section" @submit.prevent="onAdd">
<div class="mcp-config-header">
<p class="mcp-config-label">新增标注</p>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 8px">
<input v-model="form.name" placeholder="名称,如 登录按钮" class="port-input" />
<select v-model="form.selectorType" class="port-input" style="max-width: 120px">
<option value="css">CSS</option>
<option value="xpath">XPath</option>
</select>
<select v-model="form.matchType" class="port-input" style="max-width: 120px">
<option value="prefix">路径前缀</option>
<option value="exact">精确匹配</option>
<option value="host">域名</option>
</select>
</div>
<input v-model="form.selector" placeholder="CSS 选择器" class="port-input" />
<div style="display: flex; gap: 8px; margin-top: 8px">
<button class="semantic-engine-button" :disabled="!form.selector" type="submit">
保存
</button>
<button class="danger-button" type="button" @click="resetForm">清空</button>
</div>
</form>
<div v-if="markers.length" class="model-list" style="margin-top: 8px">
<div
v-for="m in markers"
:key="m.id"
class="model-card"
style="display: flex; align-items: center; justify-content: space-between; gap: 8px"
>
<div style="display: flex; flex-direction: column; gap: 4px">
<strong class="model-name">{{ m.name }}</strong>
<code style="font-size: 12px; opacity: 0.85">{{ m.selector }}</code>
<div style="display: flex; gap: 6px; margin-top: 2px">
<span class="model-tag dimension">{{ m.selectorType || 'css' }}</span>
<span class="model-tag dimension">{{ m.matchType }}</span>
</div>
</div>
<div style="display: flex; gap: 6px">
<button class="semantic-engine-button" @click="validate(m)">验证</button>
<button class="secondary-button" @click="prefill(m)">编辑</button>
<button class="danger-button" @click="remove(m)">删除</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
const currentUrl = ref('');
const markers = ref<ElementMarker[]>([]);
const form = ref<UpsertMarkerRequest>({
url: '',
name: '',
selector: '',
matchType: 'prefix',
});
function resetForm() {
form.value = { url: currentUrl.value, name: '', selector: '', matchType: 'prefix' };
}
async function load() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const t = tabs[0];
currentUrl.value = String(t?.url || '');
form.value.url = currentUrl.value;
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL,
url: currentUrl.value,
});
if (res?.success) markers.value = res.markers || [];
} catch (e) {
/* ignore */
}
}
function prefill(m: ElementMarker) {
form.value = {
url: m.url,
name: m.name,
selector: m.selector,
selectorType: m.selectorType,
listMode: m.listMode,
matchType: m.matchType,
action: m.action,
id: m.id,
};
}
async function onAdd() {
try {
if (!form.value.selector) return;
form.value.url = currentUrl.value;
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
marker: form.value,
});
if (res?.success) {
resetForm();
await load();
}
} catch (e) {
/* ignore */
}
}
async function remove(m: ElementMarker) {
try {
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,
id: m.id,
});
if (res?.success) await load();
} catch (e) {
/* ignore */
}
}
async function validate(m: ElementMarker) {
try {
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,
selector: m.selector,
selectorType: m.selectorType || 'css',
action: 'hover',
listMode: !!m.listMode,
} as any);
// Trigger highlight in the page only if tool validation succeeded
if (res?.tool?.ok !== false) {
await highlightInTab(m);
}
} catch (e) {
/* ignore */
}
}
async function highlightInTab(m: ElementMarker) {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs[0]?.id;
if (!tabId) return;
// Ensure element-marker.js is injected
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/element-marker.js'],
world: 'ISOLATED',
});
} catch {
// Already injected, ignore
}
// Send highlight message to content script
await chrome.tabs.sendMessage(tabId, {
action: 'element_marker_highlight',
selector: m.selector,
selectorType: m.selectorType || 'css',
listMode: !!m.listMode,
});
} catch (e) {
// Ignore errors (tab might not support content scripts)
}
}
onMounted(load);
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Input Container Component
*
* A reusable wrapper for inputs aligned with the attr-ui.html design spec.
* Provides container-level hover/focus-within styling with optional prefix/suffix.
*
* Design spec pattern:
* ```html
* <div class="input-bg rounded h-[28px] flex items-center px-2">
* <span class="text-gray-400 mr-2">X</span> <!-- prefix -->
* <input type="text" class="bg-transparent w-full outline-none">
* <span class="text-gray-400 text-[10px]">px</span> <!-- suffix -->
* </div>
* ```
*
* CSS classes (defined in shadow-host.ts):
* - `.we-input-container` - wrapper with hover/focus-within styles
* - `.we-input-container__input` - transparent input
* - `.we-input-container__prefix` - prefix element
* - `.we-input-container__suffix` - suffix element (typically unit)
*/
// =============================================================================
// Types
// =============================================================================
/** Content for prefix/suffix: text string or DOM node (e.g., SVG icon) */
export type InputAffix = string | Node;
export interface InputContainerOptions {
/** Accessible label for the input element */
ariaLabel: string;
/** Input type (default: "text") */
type?: string;
/** Input mode for virtual keyboard (e.g., "decimal", "numeric") */
inputMode?: string;
/** Optional prefix content (text label or icon) */
prefix?: InputAffix | null;
/** Optional suffix content (unit text or icon) */
suffix?: InputAffix | null;
/** Additional class name(s) for root container */
rootClassName?: string;
/** Additional class name(s) for input element */
inputClassName?: string;
/** Input autocomplete attribute (default: "off") */
autocomplete?: string;
/** Input spellcheck attribute (default: false) */
spellcheck?: boolean;
/** Initial placeholder text */
placeholder?: string;
}
export interface InputContainer {
/** Root container element */
root: HTMLDivElement;
/** Input element for wiring events */
input: HTMLInputElement;
/** Update prefix content */
setPrefix(content: InputAffix | null): void;
/** Update suffix content */
setSuffix(content: InputAffix | null): void;
/** Get current suffix text (null if no suffix or if suffix is a Node) */
getSuffixText(): string | null;
}
// =============================================================================
// Helpers
// =============================================================================
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function hasAffix(value: InputAffix | null | undefined): value is InputAffix {
if (value === null || value === undefined) return false;
return typeof value === 'string' ? value.trim().length > 0 : true;
}
function joinClassNames(...parts: Array<string | null | undefined | false>): string {
return parts.filter(isNonEmptyString).join(' ');
}
// =============================================================================
// Factory
// =============================================================================
/**
* Create an input container with optional prefix/suffix
*/
export function createInputContainer(options: InputContainerOptions): InputContainer {
const {
ariaLabel,
type = 'text',
inputMode,
prefix,
suffix,
rootClassName,
inputClassName,
autocomplete = 'off',
spellcheck = false,
placeholder,
} = options;
// Root container
const root = document.createElement('div');
root.className = joinClassNames('we-input-container', rootClassName);
// Prefix element (created lazily)
let prefixEl: HTMLSpanElement | null = null;
// Input element
const input = document.createElement('input');
input.type = type;
input.className = joinClassNames('we-input-container__input', inputClassName);
input.setAttribute('autocomplete', autocomplete);
input.spellcheck = spellcheck;
input.setAttribute('aria-label', ariaLabel);
if (inputMode) {
input.inputMode = inputMode;
}
if (placeholder !== undefined) {
input.placeholder = placeholder;
}
// Suffix element (created lazily)
let suffixEl: HTMLSpanElement | null = null;
// Helper: create/update affix element
function updateAffix(
kind: 'prefix' | 'suffix',
content: InputAffix | null,
existingEl: HTMLSpanElement | null,
): HTMLSpanElement | null {
if (!hasAffix(content)) {
// Remove existing element if present
if (existingEl) {
existingEl.remove();
}
return null;
}
// Create element if needed
const el = existingEl ?? document.createElement('span');
el.className = `we-input-container__${kind}`;
// Clear and set content
el.textContent = '';
if (typeof content === 'string') {
el.textContent = content;
} else {
el.append(content);
}
return el;
}
// Initial prefix
if (hasAffix(prefix)) {
prefixEl = updateAffix('prefix', prefix, null);
if (prefixEl) root.append(prefixEl);
}
// Append input
root.append(input);
// Initial suffix
if (hasAffix(suffix)) {
suffixEl = updateAffix('suffix', suffix, null);
if (suffixEl) root.append(suffixEl);
}
// Public interface
return {
root,
input,
setPrefix(content: InputAffix | null): void {
const newEl = updateAffix('prefix', content, prefixEl);
if (newEl && !prefixEl) {
// Insert before input
root.insertBefore(newEl, input);
}
prefixEl = newEl;
},
setSuffix(content: InputAffix | null): void {
const newEl = updateAffix('suffix', content, suffixEl);
if (newEl && !suffixEl) {
// Append after input
root.append(newEl);
}
suffixEl = newEl;
},
getSuffixText(): string | null {
if (!suffixEl) return null;
// Only return text content, not Node content
const text = suffixEl.textContent?.trim();
return text || null;
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts:
--------------------------------------------------------------------------------
```typescript
// expression.ts — minimal safe boolean expression evaluator (no access to global scope)
// Supported:
// - Literals: numbers (123, 1.23), strings ('x' or "x"), booleans (true/false)
// - Variables: vars.x, vars.a.b (only reads from provided vars object)
// - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, /
// - Parentheses: ( ... )
type Token = { type: string; value?: any };
function tokenize(input: string): Token[] {
const s = input.trim();
const out: Token[] = [];
let i = 0;
const isAlpha = (c: string) => /[a-zA-Z_]/.test(c);
const isNum = (c: string) => /[0-9]/.test(c);
const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c);
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
i++;
continue;
}
// operators
if (
s.startsWith('&&', i) ||
s.startsWith('||', i) ||
s.startsWith('==', i) ||
s.startsWith('!=', i) ||
s.startsWith('>=', i) ||
s.startsWith('<=', i)
) {
out.push({ type: 'op', value: s.slice(i, i + 2) });
i += 2;
continue;
}
if ('!+-*/()<>'.includes(c)) {
out.push({ type: 'op', value: c });
i++;
continue;
}
// number
if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) {
let j = i + 1;
while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++;
out.push({ type: 'num', value: parseFloat(s.slice(i, j)) });
i = j;
continue;
}
// string
if (c === '"' || c === "'") {
const quote = c;
let j = i + 1;
let str = '';
while (j < s.length) {
if (s[j] === '\\' && j + 1 < s.length) {
str += s[j + 1];
j += 2;
} else if (s[j] === quote) {
j++;
break;
} else {
str += s[j++];
}
}
out.push({ type: 'str', value: str });
i = j;
continue;
}
// identifier (vars or true/false)
if (isAlpha(c)) {
let j = i + 1;
while (j < s.length && isIdChar(s[j])) j++;
let id = s.slice(i, j);
// dotted path
while (s[j] === '.' && isAlpha(s[j + 1] || '')) {
let k = j + 1;
while (k < s.length && isIdChar(s[k])) k++;
id += s.slice(j, k);
j = k;
}
out.push({ type: 'id', value: id });
i = j;
continue;
}
// unknown token, skip to avoid crash
i++;
}
return out;
}
// Recursive descent parser
export function evalExpression(expr: string, scope: { vars: Record<string, any> }): any {
const tokens = tokenize(expr);
let i = 0;
const peek = () => tokens[i];
const consume = () => tokens[i++];
function parsePrimary(): any {
const t = peek();
if (!t) return undefined;
if (t.type === 'num') {
consume();
return t.value;
}
if (t.type === 'str') {
consume();
return t.value;
}
if (t.type === 'id') {
consume();
const id = String(t.value);
if (id === 'true') return true;
if (id === 'false') return false;
// Only allow vars.* lookups
if (!id.startsWith('vars')) return undefined;
try {
const parts = id.split('.').slice(1);
let cur: any = scope.vars;
for (const p of parts) {
if (cur == null) return undefined;
cur = cur[p];
}
return cur;
} catch {
return undefined;
}
}
if (t.type === 'op' && t.value === '(') {
consume();
const v = parseOr();
if (peek()?.type === 'op' && peek()?.value === ')') consume();
return v;
}
return undefined;
}
function parseUnary(): any {
const t = peek();
if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) {
consume();
const v = parseUnary();
return t.value === '!' ? !truthy(v) : -Number(v || 0);
}
return parsePrimary();
}
function parseMulDiv(): any {
let v = parseUnary();
while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
const op = consume().value;
const r = parseUnary();
v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0);
}
return v;
}
function parseAddSub(): any {
let v = parseMulDiv();
while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
const op = consume().value;
const r = parseMulDiv();
v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0);
}
return v;
}
function parseRel(): any {
let v = parseAddSub();
while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) {
const op = consume().value as string;
const r = parseAddSub();
const a = toComparable(v);
const b = toComparable(r);
if (op === '>') v = (a as any) > (b as any);
else if (op === '>=') v = (a as any) >= (b as any);
else if (op === '<') v = (a as any) < (b as any);
else v = (a as any) <= (b as any);
}
return v;
}
function parseEq(): any {
let v = parseRel();
while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) {
const op = consume().value as string;
const r = parseRel();
const a = toComparable(v);
const b = toComparable(r);
v = op === '==' ? a === b : a !== b;
}
return v;
}
function parseAnd(): any {
let v = parseEq();
while (peek() && peek().type === 'op' && peek().value === '&&') {
consume();
const r = parseEq();
v = truthy(v) && truthy(r);
}
return v;
}
function parseOr(): any {
let v = parseAnd();
while (peek() && peek().type === 'op' && peek().value === '||') {
consume();
const r = parseAnd();
v = truthy(v) || truthy(r);
}
return v;
}
function truthy(v: any) {
return !!v;
}
function toComparable(v: any) {
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v);
}
try {
const res = parseOr();
return res;
} catch {
return false;
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Fill Action Handler
*
* Handles form input actions:
* - Text input
* - File upload
* - Auto-scroll and focus
* - Selector fallback with logging
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import {
ensureElementVisible,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
export const fillHandler: ActionHandler<'fill'> = {
type: 'fill',
validate: (action) => {
const target = action.params.target as { ref?: string; candidates?: unknown[] };
const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
const hasValue = action.params.value !== undefined;
if (!hasValue) {
return invalid('Missing value parameter');
}
if (!hasRef && !hasCandidates) {
return invalid('Missing target selector or ref');
}
return ok();
},
describe: (action) => {
const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';
const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;
return `Fill "${displayValue}"`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
// Ensure page is read before locating element
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
// Resolve fill value
const valueResolved = resolveString(action.params.value, vars);
if (!valueResolved.ok) {
return failed('VALIDATION_ERROR', valueResolved.error);
}
const value = valueResolved.value;
// Locate target element
const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
action.params.target,
vars,
);
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
const frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
const cssSelector = !located?.ref ? firstCssOrAttr : undefined;
if (!refToUse && !cssSelector) {
return failed('TARGET_NOT_FOUND', 'Could not locate target element');
}
// Verify element visibility if we have a ref
if (located?.ref) {
const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
if (!isVisible) {
return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
}
}
// Check for file input and handle file upload
// Use firstCssOrAttr to check input type even when ref is available
const selectorForTypeCheck = firstCssOrAttr || cssSelector;
if (selectorForTypeCheck) {
const attrResult = await sendMessageToTab<{ value?: string }>(
tabId,
{ action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },
frameId,
);
const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();
if (inputType === 'file') {
const uploadResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
args: { selector: selectorForTypeCheck, filePath: value, tabId },
});
if ((uploadResult as { isError?: boolean })?.isError) {
const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'File upload failed';
return failed('UNKNOWN', errorMsg);
}
// Log fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy &&
firstCandidateType &&
resolvedBy !== 'ref' &&
resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
}
}
// Scroll element into view (best-effort)
if (cssSelector) {
try {
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,
tabId,
},
});
} catch {
// Ignore scroll errors
}
}
// Focus element (best-effort, ignore errors)
if (located?.ref) {
await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);
} else if (cssSelector) {
await handleCallTool({
name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
args: {
type: 'MAIN',
jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
tabId,
},
});
}
// Execute fill
const fillResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.FILL,
args: {
ref: refToUse,
selector: cssSelector,
value,
frameId,
tabId,
},
});
if ((fillResult as { isError?: boolean })?.isError) {
const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'Fill action failed';
return failed('UNKNOWN', errorMsg);
}
// Log fallback if used
const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="flex items-center justify-between w-full">
<!-- Brand / Context -->
<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 flex-shrink-0 ac-btn"
:style="{
color: 'var(--ac-text-muted)',
borderRadius: 'var(--ac-radius-button)',
}"
title="Back to sessions"
@click="$emit('back')"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<!-- Brand -->
<h1
class="text-lg font-medium tracking-tight flex-shrink-0"
:style="{
fontFamily: 'var(--ac-font-heading)',
color: 'var(--ac-text)',
}"
>
{{ brandLabel || 'Agent' }}
</h1>
<!-- Divider -->
<div
class="h-4 w-[1px] flex-shrink-0"
:style="{ backgroundColor: 'var(--ac-border-strong)' }"
/>
<!-- Project Breadcrumb -->
<button
class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
:style="{
fontFamily: 'var(--ac-font-mono)',
color: 'var(--ac-text-muted)',
borderRadius: 'var(--ac-radius-button)',
}"
@click="$emit('toggle:projectMenu')"
>
<span class="truncate">{{ projectLabel }}</span>
<svg
class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Session Breadcrumb -->
<div class="h-3 w-[1px] flex-shrink-0" :style="{ backgroundColor: 'var(--ac-border)' }" />
<button
class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
:style="{
fontFamily: 'var(--ac-font-mono)',
color: 'var(--ac-text-subtle)',
borderRadius: 'var(--ac-radius-button)',
}"
@click="$emit('toggle:sessionMenu')"
>
<span class="truncate">{{ sessionLabel }}</span>
<svg
class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
<!-- Connection / Status / Settings -->
<div class="flex items-center gap-3">
<!-- Connection Indicator -->
<div class="flex items-center gap-1.5" :title="connectionText">
<span
class="w-2 h-2 rounded-full"
:style="{
backgroundColor: connectionColor,
boxShadow: connectionState === 'ready' ? `0 0 8px ${connectionColor}` : 'none',
}"
/>
</div>
<!-- Open Project Button -->
<button
class="p-1 ac-btn ac-hover-text"
:style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
title="Open project in VS Code or Terminal"
@click="$emit('toggle:openProjectMenu')"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</button>
<!-- Theme & Settings Icon (Color Palette) -->
<button
class="p-1 ac-btn ac-hover-text"
:style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
@click="$emit('toggle:settingsMenu')"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
/>
</svg>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
export type ConnectionState = 'ready' | 'connecting' | 'disconnected';
const props = defineProps<{
projectLabel: string;
sessionLabel: string;
connectionState: ConnectionState;
/** Whether to show back button (for returning to sessions list) */
showBackButton?: boolean;
/** Brand label to display (e.g., "Claude Code", "Codex") */
brandLabel?: string;
}>();
defineEmits<{
'toggle:projectMenu': [];
'toggle:sessionMenu': [];
'toggle:settingsMenu': [];
'toggle:openProjectMenu': [];
/** Emitted when back button is clicked */
back: [];
}>();
const connectionColor = computed(() => {
switch (props.connectionState) {
case 'ready':
return 'var(--ac-success)';
case 'connecting':
return 'var(--ac-warning)';
default:
return 'var(--ac-text-subtle)';
}
});
const connectionText = computed(() => {
switch (props.connectionState) {
case 'ready':
return 'Connected';
case 'connecting':
return 'Connecting...';
default:
return 'Disconnected';
}
});
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @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());
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/image-utils.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Image processing utility functions
*/
/**
* Create ImageBitmap from data URL (for OffscreenCanvas)
* @param dataUrl Image data URL
* @returns Created ImageBitmap object
*/
export async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {
const response = await fetch(dataUrl);
const blob = await response.blob();
return await createImageBitmap(blob);
}
/**
* Stitch multiple image parts (dataURL) onto a single canvas
* @param parts Array of image parts, each containing dataUrl and y coordinate
* @param totalWidthPx Total width (pixels)
* @param totalHeightPx Total height (pixels)
* @returns Stitched canvas
*/
export async function stitchImages(
parts: { dataUrl: string; y: number }[],
totalWidthPx: number,
totalHeightPx: number,
): Promise<OffscreenCanvas> {
const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Unable to get canvas context');
}
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const part of parts) {
try {
const img = await createImageBitmapFromUrl(part.dataUrl);
const sx = 0;
const sy = 0;
const sWidth = img.width;
let sHeight = img.height;
const dy = part.y;
if (dy + sHeight > totalHeightPx) {
sHeight = totalHeightPx - dy;
}
if (sHeight <= 0) continue;
ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);
} catch (error) {
console.error('Error stitching image part:', error, part);
}
}
return canvas;
}
/**
* Crop image (from dataURL) to specified rectangle and resize
* @param originalDataUrl Original image data URL
* @param cropRectPx Crop rectangle (physical pixels)
* @param dpr Device pixel ratio
* @param targetWidthOpt Optional target output width (CSS pixels)
* @param targetHeightOpt Optional target output height (CSS pixels)
* @returns Cropped canvas
*/
export async function cropAndResizeImage(
originalDataUrl: string,
cropRectPx: { x: number; y: number; width: number; height: number },
dpr: number = 1,
targetWidthOpt?: number,
targetHeightOpt?: number,
): Promise<OffscreenCanvas> {
const img = await createImageBitmapFromUrl(originalDataUrl);
let sx = cropRectPx.x;
let sy = cropRectPx.y;
let sWidth = cropRectPx.width;
let sHeight = cropRectPx.height;
// Ensure crop area is within image boundaries
if (sx < 0) {
sWidth += sx;
sx = 0;
}
if (sy < 0) {
sHeight += sy;
sy = 0;
}
if (sx + sWidth > img.width) {
sWidth = img.width - sx;
}
if (sy + sHeight > img.height) {
sHeight = img.height - sy;
}
if (sWidth <= 0 || sHeight <= 0) {
throw new Error(
'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',
);
}
const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;
const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;
const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Unable to get canvas context');
}
ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);
return canvas;
}
/**
* Convert canvas to data URL
* @param canvas Canvas
* @param format Image format
* @param quality JPEG quality (0-1)
* @returns Data URL
*/
export async function canvasToDataURL(
canvas: OffscreenCanvas,
format: string = 'image/png',
quality?: number,
): Promise<string> {
const blob = await canvas.convertToBlob({
type: format,
quality: format === 'image/jpeg' ? quality : undefined,
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* Compresses an image by scaling it and converting it to a target format with a specific quality.
* This is the most effective way to reduce image data size for transport or storage.
*
* @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).
* @param {object} options - Compression options.
* @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).
* @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).
* @param {string} [options.format='image/jpeg'] - The target image format.
* @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.
*/
export async function compressImage(
imageDataUrl: string,
options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },
): Promise<{ dataUrl: string; mimeType: string }> {
const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;
// 1. Create an ImageBitmap from the original data URL for efficient drawing.
const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);
// 2. Calculate the new dimensions based on the scale factor.
const newWidth = Math.round(imageBitmap.width * scale);
const newHeight = Math.round(imageBitmap.height * scale);
// 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.
const canvas = new OffscreenCanvas(newWidth, newHeight);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context from OffscreenCanvas');
}
// 4. Draw the original image onto the smaller canvas, effectively resizing it.
ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);
// 5. Export the canvas content to the target format with the specified quality.
// This is the step that performs the data compression.
const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });
// A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet
// on all execution contexts (like service workers).
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(compressedDataUrl);
});
return { dataUrl, mimeType: format };
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Key Action Handler
*
* Handles keyboard input:
* - Resolves key sequences via variables/templates
* - Optionally focuses a target element before sending keys
* - Dispatches keyboard events via the keyboard tool
*/
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 } from '../registry';
import type { ActionHandler, ElementTarget } from '../types';
import {
ensureElementVisible,
logSelectorFallback,
resolveString,
selectorLocator,
sendMessageToTab,
toSelectorTarget,
} from './common';
/** Extract error text from tool result */
function extractToolError(result: unknown, fallback: string): string {
const content = (result as { content?: Array<{ text?: string }> })?.content;
return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
}
/** Check if target has valid selector specification */
function hasTargetSpec(target: unknown): boolean {
if (!target || typeof target !== 'object') 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 */
function stripCompositeSelector(selector: string): string {
const raw = String(selector || '').trim();
if (!raw || !raw.includes('|>')) return raw;
const parts = raw
.split('|>')
.map((p) => p.trim())
.filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : raw;
}
export const keyHandler: ActionHandler<'key'> = {
type: 'key',
validate: (action) => {
if (action.params.keys === undefined) {
return invalid('Missing keys parameter');
}
if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {
return invalid('Target must include a non-empty ref or selector candidates');
}
return ok();
},
describe: (action) => {
const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';
const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;
return `Keys "${display}"`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for key action');
}
// Resolve keys string
const keysResolved = resolveString(action.params.keys, vars);
if (!keysResolved.ok) {
return failed('VALIDATION_ERROR', keysResolved.error);
}
const keys = keysResolved.value.trim();
if (!keys) {
return failed('VALIDATION_ERROR', 'Keys string is empty');
}
let frameId = ctx.frameId;
let selectorForTool: string | undefined;
let firstCandidateType: string | undefined;
let resolvedBy: string | undefined;
// Handle optional target focusing
const target = action.params.target as ElementTarget | undefined;
if (target) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });
const {
selectorTarget,
firstCandidateType: firstType,
firstCssOrAttr,
} = toSelectorTarget(target, vars);
firstCandidateType = firstType;
const located = await selectorLocator.locate(tabId, selectorTarget, {
frameId: ctx.frameId,
preferRef: false,
});
frameId = located?.frameId ?? ctx.frameId;
const refToUse = located?.ref ?? selectorTarget.ref;
if (!refToUse && !firstCssOrAttr) {
return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');
}
resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
// Only verify visibility for freshly located refs (not stale refs from payload)
if (located?.ref) {
const visible = await ensureElementVisible(tabId, located.ref, frameId);
if (!visible) {
return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
}
const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(
tabId,
{ action: 'focusByRef', ref: located.ref },
frameId,
);
if (!focusResult.ok || focusResult.value?.success !== true) {
const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;
if (!firstCssOrAttr) {
return failed(
'TARGET_NOT_FOUND',
`Failed to focus target element: ${focusErr || 'ref may be stale'}`,
);
}
ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');
}
// Try to resolve ref to CSS selector for tool
const resolved = await sendMessageToTab<{
success?: boolean;
selector?: string;
error?: string;
}>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);
if (
resolved.ok &&
resolved.value?.success !== false &&
typeof resolved.value?.selector === 'string'
) {
const sel = resolved.value.selector.trim();
if (sel) selectorForTool = sel;
}
}
// Fallback to CSS/attr selector
if (!selectorForTool && firstCssOrAttr) {
const stripped = stripCompositeSelector(firstCssOrAttr);
if (stripped) selectorForTool = stripped;
}
}
// Execute keyboard input
const keyboardResult = await handleCallTool({
name: TOOL_NAMES.BROWSER.KEYBOARD,
args: {
keys,
selector: selectorForTool,
selectorType: selectorForTool ? 'css' : undefined,
tabId,
frameId,
},
});
if ((keyboardResult as { isError?: boolean })?.isError) {
return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));
}
// Log fallback after successful execution
const fallbackUsed =
resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
if (fallbackUsed) {
logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
}
return { status: 'success' };
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Wait Action Handler
*
* Handles various wait conditions:
* - Sleep (fixed delay)
* - Network idle
* - Navigation complete
* - Text appears/disappears
* - Selector visible/hidden
*/
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { waitForNavigation, waitForNetworkIdle } from '../../rr-utils';
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, resolveString, sendMessageToTab } from './common';
export const waitHandler: ActionHandler<'wait'> = {
type: 'wait',
validate: (action) => {
const condition = action.params.condition;
if (!condition || typeof condition !== 'object') {
return invalid('Missing condition parameter');
}
if (!('kind' in condition)) {
return invalid('Condition must have a kind property');
}
return ok();
},
describe: (action) => {
const condition = action.params.condition;
if (!condition) return 'Wait';
switch (condition.kind) {
case 'sleep': {
const ms = typeof condition.sleep === 'number' ? condition.sleep : '(dynamic)';
return `Wait ${ms}ms`;
}
case 'networkIdle':
return 'Wait for network idle';
case 'navigation':
return 'Wait for navigation';
case 'text': {
const appear = condition.appear !== false;
const text = typeof condition.text === 'string' ? condition.text : '(dynamic)';
const displayText = text.length > 20 ? text.slice(0, 20) + '...' : text;
return `Wait for text "${displayText}" to ${appear ? 'appear' : 'disappear'}`;
}
case 'selector': {
const visible = condition.visible !== false;
return `Wait for selector to be ${visible ? 'visible' : 'hidden'}`;
}
default:
return 'Wait';
}
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
const timeoutMs = action.policy?.timeout?.ms;
const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
const condition = action.params.condition;
// Handle sleep condition
if (condition.kind === 'sleep') {
const msResolved = tryResolveNumber(condition.sleep, vars);
if (!msResolved.ok) {
return failed('VALIDATION_ERROR', msResolved.error);
}
const ms = Math.max(0, Number(msResolved.value ?? 0));
await new Promise((resolve) => setTimeout(resolve, ms));
return { status: 'success' };
}
// Handle network idle condition
if (condition.kind === 'networkIdle') {
const totalMs = clampInt(timeoutMs ?? 5000, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
let idleMs: number;
if (condition.idleMs !== undefined) {
const idleResolved = tryResolveNumber(condition.idleMs, vars);
idleMs = idleResolved.ok
? clampInt(idleResolved.value, 200, 5000)
: Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
} else {
idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
}
await waitForNetworkIdle(totalMs, idleMs);
return { status: 'success' };
}
// Handle navigation condition
if (condition.kind === 'navigation') {
const timeout = timeoutMs === undefined ? undefined : Math.max(0, Number(timeoutMs));
await waitForNavigation(timeout);
return { status: 'success' };
}
// Handle text condition
if (condition.kind === 'text') {
const textResolved = resolveString(condition.text, vars);
if (!textResolved.ok) {
return failed('VALIDATION_ERROR', textResolved.error);
}
const appear = condition.appear !== false;
const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);
// Inject wait helper script
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
});
} catch (e) {
return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
}
// Execute wait for text
const response = await sendMessageToTab<{ success?: boolean }>(
tabId,
{ action: 'waitForText', text: textResolved.value, appear, timeout },
ctx.frameId,
);
if (!response.ok) {
return failed('TIMEOUT', `Wait for text failed: ${response.error}`);
}
if (response.value?.success !== true) {
return failed(
'TIMEOUT',
`Text "${textResolved.value}" did not ${appear ? 'appear' : 'disappear'} within timeout`,
);
}
return { status: 'success' };
}
// Handle selector condition
if (condition.kind === 'selector') {
const selectorResolved = resolveString(condition.selector, vars);
if (!selectorResolved.ok) {
return failed('VALIDATION_ERROR', selectorResolved.error);
}
const visible = condition.visible !== false;
const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);
// Inject wait helper script
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
files: ['inject-scripts/wait-helper.js'],
world: 'ISOLATED',
});
} catch (e) {
return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
}
// Execute wait for selector
const response = await sendMessageToTab<{ success?: boolean }>(
tabId,
{ action: 'waitForSelector', selector: selectorResolved.value, visible, timeout },
ctx.frameId,
);
if (!response.ok) {
return failed('TIMEOUT', `Wait for selector failed: ${response.error}`);
}
if (response.value?.success !== true) {
return failed(
'TIMEOUT',
`Selector "${selectorResolved.value}" did not become ${visible ? 'visible' : 'hidden'} within timeout`,
);
}
return { status: 'success' };
}
return failed(
'VALIDATION_ERROR',
`Unsupported wait condition kind: ${(condition as { kind: string }).kind}`,
);
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview ContextMenu Trigger Handler (P4-05)
* @description
* Uses `chrome.contextMenus` API to create right-click menu items that fire triggers.
*
* Design notes:
* - Each trigger creates a separate menu item with unique ID
* - Menu item ID is prefixed with 'rr_v3_' to avoid conflicts
* - Context types: 'page', 'selection', 'link', 'image', 'video', 'audio', etc.
* - Captures click info and tab info for trigger context
*/
import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
// ==================== Types ====================
export interface ContextMenuTriggerHandlerDeps {
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}
type ContextMenuTriggerSpec = TriggerSpecByKind<'contextMenu'>;
interface InstalledContextMenuTrigger {
spec: ContextMenuTriggerSpec;
menuItemId: string;
}
// ==================== Constants ====================
const MENU_ITEM_PREFIX = 'rr_v3_';
// Default context types if not specified
const DEFAULT_CONTEXTS: chrome.contextMenus.ContextType[] = ['page'];
// ==================== Handler Implementation ====================
/**
* Create context menu trigger handler factory
*/
export function createContextMenuTriggerHandlerFactory(
deps?: ContextMenuTriggerHandlerDeps,
): TriggerHandlerFactory<'contextMenu'> {
return (fireCallback) => createContextMenuTriggerHandler(fireCallback, deps);
}
/**
* Create context menu trigger handler
*/
export function createContextMenuTriggerHandler(
fireCallback: TriggerFireCallback,
deps?: ContextMenuTriggerHandlerDeps,
): TriggerHandler<'contextMenu'> {
const logger = deps?.logger ?? console;
// Map menuItemId -> triggerId for fast lookup
const menuItemIdToTriggerId = new Map<string, TriggerId>();
const installed = new Map<TriggerId, InstalledContextMenuTrigger>();
let listening = false;
/**
* Generate unique menu item ID for a trigger
*/
function generateMenuItemId(triggerId: TriggerId): string {
return `${MENU_ITEM_PREFIX}${triggerId}`;
}
/**
* Handle chrome.contextMenus.onClicked event
*/
const onClicked = (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): void => {
const menuItemId = String(info.menuItemId);
const triggerId = menuItemIdToTriggerId.get(menuItemId);
if (!triggerId) return;
const trigger = installed.get(triggerId);
if (!trigger) return;
// Fire and forget: chrome event listeners should not block
Promise.resolve(
fireCallback.onFire(triggerId, {
sourceTabId: tab?.id,
sourceUrl: info.pageUrl ?? tab?.url,
}),
).catch((e) => {
logger.error(`[ContextMenuTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
});
};
/**
* Ensure listener is registered
*/
function ensureListening(): void {
if (listening) return;
if (!chrome.contextMenus?.onClicked?.addListener) {
logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.onClicked is unavailable');
return;
}
chrome.contextMenus.onClicked.addListener(onClicked);
listening = true;
}
/**
* Stop listening
*/
function stopListening(): void {
if (!listening) return;
try {
chrome.contextMenus.onClicked.removeListener(onClicked);
} catch (e) {
logger.debug('[ContextMenuTriggerHandler] removeListener failed:', e);
} finally {
listening = false;
}
}
/**
* Convert context types from spec to chrome API format
*/
function normalizeContexts(
contexts: ReadonlyArray<string> | undefined,
): chrome.contextMenus.ContextType[] {
if (!contexts || contexts.length === 0) {
return DEFAULT_CONTEXTS;
}
return contexts as chrome.contextMenus.ContextType[];
}
return {
kind: 'contextMenu',
async install(trigger: ContextMenuTriggerSpec): Promise<void> {
const { id, title, contexts } = trigger;
const menuItemId = generateMenuItemId(id);
// Check if chrome.contextMenus.create is available
if (!chrome.contextMenus?.create) {
logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.create is unavailable');
return;
}
// Create menu item
await new Promise<void>((resolve, reject) => {
chrome.contextMenus.create(
{
id: menuItemId,
title: title,
contexts: normalizeContexts(contexts),
},
() => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
},
);
});
installed.set(id, { spec: trigger, menuItemId });
menuItemIdToTriggerId.set(menuItemId, id);
ensureListening();
},
async uninstall(triggerId: string): Promise<void> {
const trigger = installed.get(triggerId as TriggerId);
if (!trigger) return;
// Remove menu item
if (chrome.contextMenus?.remove) {
await new Promise<void>((resolve) => {
chrome.contextMenus.remove(trigger.menuItemId, () => {
// Ignore errors (item may not exist)
if (chrome.runtime.lastError) {
logger.debug(
`[ContextMenuTriggerHandler] Failed to remove menu item: ${chrome.runtime.lastError.message}`,
);
}
resolve();
});
});
}
menuItemIdToTriggerId.delete(trigger.menuItemId);
installed.delete(triggerId as TriggerId);
if (installed.size === 0) {
stopListening();
}
},
async uninstallAll(): Promise<void> {
// Remove all menu items created by this handler
if (chrome.contextMenus?.remove) {
const removePromises = Array.from(installed.values()).map(
(trigger) =>
new Promise<void>((resolve) => {
chrome.contextMenus.remove(trigger.menuItemId, () => {
// Ignore errors
resolve();
});
}),
);
await Promise.all(removePromises);
}
installed.clear();
menuItemIdToTriggerId.clear();
stopListening();
},
getInstalledIds(): string[] {
return Array.from(installed.keys());
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Legacy Step Types for Record & Replay
*
* This file contains the legacy Step type system that is being phased out
* in favor of the DAG-based execution model (nodes/edges).
*
* These types are kept for:
* 1. Backward compatibility with existing flows that use steps array
* 2. Recording pipeline that still produces Step[] output
* 3. Legacy node handlers in nodes/ directory
*
* New code should use the Action type system from ./actions/types.ts instead.
*
* Migration status: P4 phase 1 - types extracted, re-exported from types.ts
*/
import { STEP_TYPES } from '@/common/step-types';
// =============================================================================
// Legacy Selector Types
// =============================================================================
export type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';
export interface SelectorCandidate {
type: SelectorType;
value: string; // literal selector or text/aria expression
weight?: number; // user-adjustable priority; higher first
}
export interface TargetLocator {
ref?: string; // ephemeral ref from read_page
candidates: SelectorCandidate[]; // ordered by priority
}
// =============================================================================
// Legacy Step Types
// =============================================================================
export type StepType = (typeof STEP_TYPES)[keyof typeof STEP_TYPES];
export interface StepBase {
id: string;
type: StepType;
timeoutMs?: number; // default 10000
retry?: { count: number; intervalMs: number; backoff?: 'none' | 'exp' };
screenshotOnFail?: boolean; // default true
}
export interface StepClick extends StepBase {
type: 'click' | 'dblclick';
target: TargetLocator;
before?: { scrollIntoView?: boolean; waitForSelector?: boolean };
after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean };
}
export interface StepFill extends StepBase {
type: 'fill';
target: TargetLocator;
value: string; // may contain {var}
}
export interface StepTriggerEvent extends StepBase {
type: 'triggerEvent';
target: TargetLocator;
event: string; // e.g. 'input', 'change', 'mouseover'
bubbles?: boolean;
cancelable?: boolean;
}
export interface StepSetAttribute extends StepBase {
type: 'setAttribute';
target: TargetLocator;
name: string;
value?: string; // when omitted and remove=true, remove attribute
remove?: boolean;
}
export interface StepScreenshot extends StepBase {
type: 'screenshot';
selector?: string;
fullPage?: boolean;
saveAs?: string; // variable name to store base64
}
export interface StepSwitchFrame extends StepBase {
type: 'switchFrame';
frame?: { index?: number; urlContains?: string };
}
export interface StepLoopElements extends StepBase {
type: 'loopElements';
selector: string;
saveAs?: string; // list var name
itemVar?: string; // default 'item'
subflowId: string;
}
export interface StepKey extends StepBase {
type: 'key';
keys: string; // e.g. "Backspace Enter" or "cmd+a"
target?: TargetLocator; // optional focus target
}
export interface StepScroll extends StepBase {
type: 'scroll';
mode: 'element' | 'offset' | 'container';
target?: TargetLocator; // when mode = element / container
offset?: { x?: number; y?: number };
}
export interface StepDrag extends StepBase {
type: 'drag';
start: TargetLocator;
end: TargetLocator;
path?: Array<{ x: number; y: number }>; // sampled trajectory
}
export interface StepWait extends StepBase {
type: 'wait';
condition:
| { selector: string; visible?: boolean }
| { text: string; appear?: boolean }
| { navigation: true }
| { networkIdle: true }
| { sleep: number };
}
export interface StepAssert extends StepBase {
type: 'assert';
assert:
| { exists: string }
| { visible: string }
| { textPresent: string }
| { attribute: { selector: string; name: string; equals?: string; matches?: string } };
// 失败策略:stop=失败即停(默认)、warn=仅告警并继续、retry=触发重试机制
failStrategy?: 'stop' | 'warn' | 'retry';
}
export interface StepScript extends StepBase {
type: 'script';
world?: 'MAIN' | 'ISOLATED';
code: string; // user script string
when?: 'before' | 'after';
}
export interface StepIf extends StepBase {
type: 'if';
// condition supports: { var: string; equals?: any } | { expression: string }
condition: any;
}
export interface StepForeach extends StepBase {
type: 'foreach';
listVar: string;
itemVar?: string;
subflowId: string;
}
export interface StepWhile extends StepBase {
type: 'while';
condition: any;
subflowId: string;
maxIterations?: number;
}
export interface StepHttp extends StepBase {
type: 'http';
method?: string;
url: string;
headers?: Record<string, string>;
body?: any;
formData?: any;
saveAs?: string;
assign?: Record<string, string>;
}
export interface StepExtract extends StepBase {
type: 'extract';
selector?: string;
attr?: string; // 'text'|'textContent' to read text
js?: string; // custom JS that returns value
saveAs: string;
}
export interface StepOpenTab extends StepBase {
type: 'openTab';
url?: string;
newWindow?: boolean;
}
export interface StepSwitchTab extends StepBase {
type: 'switchTab';
tabId?: number;
urlContains?: string;
titleContains?: string;
}
export interface StepCloseTab extends StepBase {
type: 'closeTab';
tabIds?: number[];
url?: string;
}
export interface StepNavigate extends StepBase {
type: 'navigate';
url: string;
}
export interface StepHandleDownload extends StepBase {
type: 'handleDownload';
filenameContains?: string;
saveAs?: string;
waitForComplete?: boolean;
}
export interface StepExecuteFlow extends StepBase {
type: 'executeFlow';
flowId: string;
inline?: boolean;
args?: Record<string, any>;
}
// =============================================================================
// Step Union Type
// =============================================================================
export type Step =
| StepClick
| StepFill
| StepTriggerEvent
| StepSetAttribute
| StepScreenshot
| StepSwitchFrame
| StepLoopElements
| StepKey
| StepScroll
| StepDrag
| StepWait
| StepAssert
| StepScript
| StepIf
| StepForeach
| StepWhile
| StepNavigate
| StepHttp
| StepExtract
| StepOpenTab
| StepSwitchTab
| StepCloseTab
| StepHandleDownload
| StepExecuteFlow;
```
--------------------------------------------------------------------------------
/app/native-server/src/agent/message-service.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Message Service - Database-backed implementation using Drizzle ORM.
*
* Provides CRUD operations for agent chat messages with:
* - Type-safe database queries
* - Efficient indexed queries
* - Consistent with AgentStoredMessage interface from shared types
*/
import { randomUUID } from 'node:crypto';
import { eq, asc, and, count } from 'drizzle-orm';
import type { AgentRole, AgentStoredMessage } from 'chrome-mcp-shared';
import { getDb, messages, type MessageRow } from './db';
// ============================================================
// Types
// ============================================================
export type { AgentStoredMessage };
export interface CreateAgentStoredMessageInput {
projectId: string;
role: AgentRole;
messageType: AgentStoredMessage['messageType'];
content: string;
metadata?: Record<string, unknown>;
sessionId?: string;
conversationId?: string | null;
cliSource?: string;
requestId?: string;
id?: string;
createdAt?: string;
}
// ============================================================
// Type Conversion
// ============================================================
/**
* Convert database row to AgentStoredMessage interface.
*/
function rowToMessage(row: MessageRow): AgentStoredMessage {
return {
id: row.id,
projectId: row.projectId,
sessionId: row.sessionId,
conversationId: row.conversationId,
role: row.role as AgentRole,
content: row.content,
messageType: row.messageType as AgentStoredMessage['messageType'],
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
cliSource: row.cliSource,
requestId: row.requestId ?? undefined,
createdAt: row.createdAt,
};
}
// ============================================================
// Public API
// ============================================================
/**
* Get messages by project ID with pagination.
* Returns messages sorted by creation time (oldest first).
*/
export async function getMessagesByProjectId(
projectId: string,
limit = 50,
offset = 0,
): Promise<AgentStoredMessage[]> {
const db = getDb();
const query = db
.select()
.from(messages)
.where(eq(messages.projectId, projectId))
.orderBy(asc(messages.createdAt));
// Apply pagination if specified
if (limit > 0) {
query.limit(limit);
}
if (offset > 0) {
query.offset(offset);
}
const rows = await query;
return rows.map(rowToMessage);
}
/**
* Get the total count of messages for a project.
*/
export async function getMessagesCountByProjectId(projectId: string): Promise<number> {
const db = getDb();
const result = await db
.select({ count: count() })
.from(messages)
.where(eq(messages.projectId, projectId));
return result[0]?.count ?? 0;
}
/**
* Create a new message.
*/
export async function createMessage(
input: CreateAgentStoredMessageInput,
): Promise<AgentStoredMessage> {
const db = getDb();
const now = new Date().toISOString();
const messageData: MessageRow = {
id: input.id?.trim() || randomUUID(),
projectId: input.projectId,
sessionId: input.sessionId || '',
conversationId: input.conversationId ?? null,
role: input.role,
content: input.content,
messageType: input.messageType,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
cliSource: input.cliSource ?? null,
requestId: input.requestId ?? null,
createdAt: input.createdAt || now,
};
await db
.insert(messages)
.values(messageData)
.onConflictDoUpdate({
target: messages.id,
set: {
role: messageData.role,
messageType: messageData.messageType,
content: messageData.content,
metadata: messageData.metadata,
sessionId: messageData.sessionId,
conversationId: messageData.conversationId,
cliSource: messageData.cliSource,
requestId: messageData.requestId,
},
});
return rowToMessage(messageData);
}
/**
* Delete messages by project ID.
* Optionally filter by conversation ID.
* Returns the number of deleted messages.
*/
export async function deleteMessagesByProjectId(
projectId: string,
conversationId?: string,
): Promise<number> {
const db = getDb();
// Get count before deletion
const beforeCount = await getMessagesCountByProjectId(projectId);
if (conversationId) {
await db
.delete(messages)
.where(and(eq(messages.projectId, projectId), eq(messages.conversationId, conversationId)));
} else {
await db.delete(messages).where(eq(messages.projectId, projectId));
}
// Get count after deletion to calculate deleted count
const afterCount = await getMessagesCountByProjectId(projectId);
return beforeCount - afterCount;
}
/**
* Get messages by session ID with optional pagination.
* Returns messages sorted by creation time (oldest first).
*
* @param sessionId - The session ID to filter by
* @param limit - Maximum number of messages to return (0 = no limit)
* @param offset - Number of messages to skip
*/
export async function getMessagesBySessionId(
sessionId: string,
limit = 0,
offset = 0,
): Promise<AgentStoredMessage[]> {
const db = getDb();
const query = db
.select()
.from(messages)
.where(eq(messages.sessionId, sessionId))
.orderBy(asc(messages.createdAt));
if (limit > 0) {
query.limit(limit);
}
if (offset > 0) {
query.offset(offset);
}
const rows = await query;
return rows.map(rowToMessage);
}
/**
* Get count of messages by session ID.
*/
export async function getMessagesCountBySessionId(sessionId: string): Promise<number> {
const db = getDb();
const result = await db
.select({ count: count() })
.from(messages)
.where(eq(messages.sessionId, sessionId));
return result[0]?.count ?? 0;
}
/**
* Delete all messages for a session.
* Returns the number of deleted messages.
*/
export async function deleteMessagesBySessionId(sessionId: string): Promise<number> {
const db = getDb();
const beforeCount = await getMessagesCountBySessionId(sessionId);
await db.delete(messages).where(eq(messages.sessionId, sessionId));
const afterCount = await getMessagesCountBySessionId(sessionId);
return beforeCount - afterCount;
}
/**
* Get messages by request ID.
*/
export async function getMessagesByRequestId(requestId: string): Promise<AgentStoredMessage[]> {
const db = getDb();
const rows = await db
.select()
.from(messages)
.where(eq(messages.requestId, requestId))
.orderBy(asc(messages.createdAt));
return rows.map(rowToMessage);
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/element-picker.content.ts:
--------------------------------------------------------------------------------
```typescript
/**
* 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;
});
},
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolResultCardStep.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="space-y-2">
<!-- Label + Title + Diff Stats -->
<div class="flex items-baseline gap-2 flex-wrap">
<span
class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
:style="{ color: labelColor }"
>
{{ item.tool.label }}
</span>
<code
class="text-xs font-semibold"
:style="{
fontFamily: 'var(--ac-font-mono)',
color: 'var(--ac-text)',
}"
:title="item.tool.filePath"
>
{{ item.tool.title }}
</code>
<!-- Diff Stats Badge -->
<span
v-if="hasDiffStats"
class="text-[10px] px-1.5 py-0.5"
:style="{
backgroundColor: 'var(--ac-chip-bg)',
color: 'var(--ac-text-muted)',
fontFamily: 'var(--ac-font-mono)',
borderRadius: 'var(--ac-radius-button)',
}"
>
<span v-if="item.tool.diffStats?.addedLines" class="text-green-600 dark:text-green-400">
+{{ item.tool.diffStats.addedLines }}
</span>
<span v-if="item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines">/</span>
<span v-if="item.tool.diffStats?.deletedLines" class="text-red-600 dark:text-red-400">
-{{ item.tool.diffStats.deletedLines }}
</span>
<span
v-if="
!item.tool.diffStats?.addedLines &&
!item.tool.diffStats?.deletedLines &&
item.tool.diffStats?.totalLines
"
>
{{ item.tool.diffStats.totalLines }} lines
</span>
</span>
</div>
<!-- File Path (if different from title) -->
<div
v-if="showFilePath"
class="text-[10px] pl-10 truncate"
:style="{ color: 'var(--ac-text-subtle)' }"
:title="item.tool.filePath"
>
{{ item.tool.filePath }}
</div>
<!-- Result Card -->
<div
v-if="showCard"
class="overflow-hidden text-xs leading-5"
:style="{
fontFamily: 'var(--ac-font-mono)',
border: 'var(--ac-border-width) solid var(--ac-code-border)',
boxShadow: 'var(--ac-shadow-card)',
borderRadius: 'var(--ac-radius-inner)',
}"
>
<!-- File list for edit -->
<template v-if="item.tool.kind === 'edit' && item.tool.files?.length">
<div
v-for="(file, idx) in item.tool.files.slice(0, 5)"
:key="file"
class="px-3 py-1"
:style="{
backgroundColor: 'var(--ac-surface)',
borderBottom:
idx === Math.min(item.tool.files.length, 5) - 1
? 'none'
: 'var(--ac-border-width) solid var(--ac-border)',
color: 'var(--ac-text-muted)',
}"
>
{{ file }}
</div>
<div
v-if="item.tool.files.length > 5"
class="px-3 py-1 text-[10px]"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
color: 'var(--ac-text-subtle)',
}"
>
+{{ item.tool.files.length - 5 }} more files
</div>
</template>
<!-- Command output -->
<template v-else-if="item.tool.kind === 'run' && item.tool.details">
<div
class="px-3 py-2 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto ac-scroll"
:style="{
backgroundColor: 'var(--ac-code-bg)',
color: 'var(--ac-code-text)',
}"
>
{{ truncatedDetails }}
</div>
<button
v-if="isDetailsTruncated"
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
color: 'var(--ac-link)',
}"
@click="expanded = !expanded"
>
{{ expanded ? 'Show less' : 'Show more...' }}
</button>
</template>
<!-- Generic details -->
<template v-else-if="item.tool.details">
<div
class="px-3 py-2 whitespace-pre-wrap break-words max-h-[150px] overflow-y-auto ac-scroll"
:style="{
backgroundColor: 'var(--ac-code-bg)',
color: 'var(--ac-code-text)',
}"
>
{{ truncatedDetails }}
</div>
<button
v-if="isDetailsTruncated"
class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
:style="{
backgroundColor: 'var(--ac-surface-muted)',
color: 'var(--ac-link)',
}"
@click="expanded = !expanded"
>
{{ expanded ? 'Show less' : 'Show more...' }}
</button>
</template>
</div>
<!-- Error indicator -->
<div v-if="item.isError" class="text-[11px]" :style="{ color: 'var(--ac-danger)' }">
Error occurred
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { TimelineItem } from '../../../composables/useAgentThreads';
const props = defineProps<{
item: Extract<TimelineItem, { kind: 'tool_result' }>;
}>();
const expanded = ref(false);
const MAX_LINES = 10;
const MAX_CHARS = 500;
const labelColor = computed(() => {
if (props.item.isError) {
return 'var(--ac-danger)';
}
if (props.item.tool.kind === 'edit') {
return 'var(--ac-accent)';
}
return 'var(--ac-success)';
});
const hasDiffStats = computed(() => {
const stats = props.item.tool.diffStats;
if (!stats) return false;
return (
stats.addedLines !== undefined ||
stats.deletedLines !== undefined ||
stats.totalLines !== undefined
);
});
const showFilePath = computed(() => {
const tool = props.item.tool;
// Show full path if title is just the filename
if (!tool.filePath) return false;
return tool.filePath !== tool.title && !tool.title.includes('/');
});
const showCard = computed(() => {
const tool = props.item.tool;
return (
(tool.kind === 'edit' && tool.files?.length) ||
(tool.kind === 'run' && tool.details) ||
tool.details
);
});
const isDetailsTruncated = computed(() => {
const details = props.item.tool.details ?? '';
const lines = details.split('\n');
return lines.length > MAX_LINES || details.length > MAX_CHARS;
});
const truncatedDetails = computed(() => {
const details = props.item.tool.details ?? '';
if (expanded.value) {
return details;
}
const lines = details.split('\n');
if (lines.length > MAX_LINES) {
return lines.slice(0, MAX_LINES).join('\n');
}
if (details.length > MAX_CHARS) {
return details.slice(0, MAX_CHARS);
}
return details;
});
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div ref="shellRef" class="h-full flex flex-col overflow-hidden relative">
<!-- Header -->
<header
class="flex-none px-5 py-3 flex items-center justify-between z-20"
:style="{
backgroundColor: 'var(--ac-header-bg)',
borderBottom: 'var(--ac-border-width) solid var(--ac-header-border)',
backdropFilter: 'blur(8px)',
}"
>
<slot name="header" />
</header>
<!-- Content Area -->
<main
ref="contentRef"
class="flex-1 overflow-y-auto ac-scroll"
:style="{
paddingBottom: composerHeight + 'px',
}"
@scroll="handleScroll"
>
<!-- Stable wrapper for ResizeObserver -->
<div ref="contentSlotRef">
<slot name="content" />
</div>
</main>
<!-- Footer / Composer -->
<footer
ref="composerRef"
class="flex-none px-5 pb-5 pt-2"
:style="{
background: `linear-gradient(to top, var(--ac-bg), var(--ac-bg), transparent)`,
}"
>
<!-- Error Banner (above input) -->
<div
v-if="errorMessage"
class="mb-2 px-4 py-2 text-xs rounded-lg flex items-start gap-2"
:style="{
backgroundColor: 'var(--ac-diff-del-bg)',
color: 'var(--ac-danger)',
border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',
borderRadius: 'var(--ac-radius-inner)',
}"
>
<!-- Error message with scroll for long content -->
<div
class="min-w-0 flex-1 whitespace-pre-wrap break-all ac-scroll"
:style="{ maxHeight: '30vh', overflowY: 'auto', overflowWrap: 'anywhere' }"
>
{{ errorMessage }}
</div>
<!-- Dismiss button -->
<button
type="button"
class="p-1 flex-shrink-0 ac-btn ac-focus-ring cursor-pointer"
:style="{
color: 'var(--ac-danger)',
borderRadius: 'var(--ac-radius-button)',
}"
aria-label="Dismiss error"
title="Dismiss"
@click="emit('error:dismiss')"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<slot name="composer" />
<!-- Usage & Version label -->
<div
class="text-[10px] text-center mt-2 font-medium tracking-wide flex items-center justify-center gap-2"
:style="{ color: 'var(--ac-text-subtle)' }"
>
<template v-if="usage">
<span
:title="`Input: ${usage.inputTokens.toLocaleString()}, Output: ${usage.outputTokens.toLocaleString()}`"
>
{{ formatTokens(usage.inputTokens + usage.outputTokens) }} tokens
</span>
<span class="opacity-50">·</span>
<span
:title="`Duration: ${(usage.durationMs / 1000).toFixed(1)}s, Turns: ${usage.numTurns}`"
>
${{ usage.totalCostUsd.toFixed(4) }}
</span>
<span class="opacity-50">·</span>
</template>
<span>{{ footerLabel || 'Agent Preview' }}</span>
</div>
</footer>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue';
import type { AgentUsageStats } from 'chrome-mcp-shared';
defineProps<{
errorMessage?: string | null;
usage?: AgentUsageStats | null;
/** Footer label to display (e.g., "Claude Code Preview", "Codex Preview") */
footerLabel?: string;
}>();
const emit = defineEmits<{
/** Emitted when user clicks dismiss button on error banner */
'error:dismiss': [];
}>();
/**
* Format token count for display (e.g., 1.2k, 3.5M)
*/
function formatTokens(count: number): string {
if (count >= 1_000_000) {
return (count / 1_000_000).toFixed(1) + 'M';
}
if (count >= 1_000) {
return (count / 1_000).toFixed(1) + 'k';
}
return count.toString();
}
const shellRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
const contentSlotRef = ref<HTMLElement | null>(null);
const composerRef = ref<HTMLElement | null>(null);
const composerHeight = ref(120); // Default height
// Auto-scroll state
const isUserScrolledUp = ref(false);
// Threshold should account for padding and some tolerance
const SCROLL_THRESHOLD = 150;
/**
* Check if scroll position is near bottom
*/
function isNearBottom(el: HTMLElement): boolean {
const { scrollTop, scrollHeight, clientHeight } = el;
return scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
}
/**
* Handle user scroll to track if they've scrolled up
*/
function handleScroll(): void {
if (!contentRef.value) return;
isUserScrolledUp.value = !isNearBottom(contentRef.value);
}
/**
* Scroll to bottom of content area
*/
function scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
if (!contentRef.value) return;
contentRef.value.scrollTo({
top: contentRef.value.scrollHeight,
behavior,
});
}
// Observers
let composerResizeObserver: ResizeObserver | null = null;
let contentResizeObserver: ResizeObserver | null = null;
// Scroll scheduling to prevent excessive calls during streaming
let scrollScheduled = false;
/**
* Auto-scroll when content or composer changes (if user is at bottom)
* Uses requestAnimationFrame to debounce rapid updates during streaming
*/
function maybeAutoScroll(): void {
if (scrollScheduled || isUserScrolledUp.value || !contentRef.value) {
return;
}
scrollScheduled = true;
requestAnimationFrame(() => {
scrollScheduled = false;
if (!isUserScrolledUp.value) {
scrollToBottom('auto');
}
});
}
onMounted(() => {
// Observe composer height changes
if (composerRef.value) {
composerResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
composerHeight.value = entry.contentRect.height + 24; // Add padding
}
// Also auto-scroll when composer height changes (e.g., error banner appears)
maybeAutoScroll();
});
composerResizeObserver.observe(composerRef.value);
}
// Observe content height changes for auto-scroll using stable wrapper
if (contentSlotRef.value) {
contentResizeObserver = new ResizeObserver(() => {
maybeAutoScroll();
});
contentResizeObserver.observe(contentSlotRef.value);
}
});
onUnmounted(() => {
composerResizeObserver?.disconnect();
contentResizeObserver?.disconnect();
});
// Expose scrollToBottom for parent component to call
defineExpose({
scrollToBottom,
});
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ModelCacheManagement.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="model-cache-section">
<h2 class="section-title">{{ getMessage('modelCacheManagementLabel') }}</h2>
<!-- Cache Statistics Grid -->
<div class="stats-grid">
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('cacheSizeLabel') }}</p>
<span class="stats-icon orange">
<DatabaseIcon />
</span>
</div>
<p class="stats-value">{{ cacheStats?.totalSizeMB || 0 }} MB</p>
</div>
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('cacheEntriesLabel') }}</p>
<span class="stats-icon purple">
<VectorIcon />
</span>
</div>
<p class="stats-value">{{ cacheStats?.entryCount || 0 }}</p>
</div>
</div>
<!-- Cache Entries Details -->
<div v-if="cacheStats && cacheStats.entries.length > 0" class="cache-details">
<h3 class="cache-details-title">{{ getMessage('cacheDetailsLabel') }}</h3>
<div class="cache-entries">
<div v-for="entry in cacheStats.entries" :key="entry.url" class="cache-entry">
<div class="entry-info">
<div class="entry-url">{{ getModelNameFromUrl(entry.url) }}</div>
<div class="entry-details">
<span class="entry-size">{{ entry.sizeMB }} MB</span>
<span class="entry-age">{{ entry.age }}</span>
<span v-if="entry.expired" class="entry-expired">{{ getMessage('expiredLabel') }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- No Cache Message -->
<div v-else-if="cacheStats && cacheStats.entries.length === 0" class="no-cache">
<p>{{ getMessage('noCacheDataMessage') }}</p>
</div>
<!-- Loading State -->
<div v-else-if="!cacheStats" class="loading-cache">
<p>{{ getMessage('loadingCacheInfoStatus') }}</p>
</div>
<!-- Progress Indicator -->
<ProgressIndicator
v-if="isManagingCache"
:visible="isManagingCache"
:text="isManagingCache ? getMessage('processingCacheStatus') : ''"
:showSpinner="true"
/>
<!-- Action Buttons -->
<div class="cache-actions">
<div class="secondary-button" :disabled="isManagingCache" @click="$emit('cleanup-cache')">
<span class="stats-icon"><DatabaseIcon /></span>
<span>{{
isManagingCache ? getMessage('cleaningStatus') : getMessage('cleanExpiredCacheButton')
}}</span>
</div>
<div class="danger-button" :disabled="isManagingCache" @click="$emit('clear-all-cache')">
<span class="stats-icon"><TrashIcon /></span>
<span>{{ isManagingCache ? getMessage('clearingStatus') : getMessage('clearAllCacheButton') }}</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import ProgressIndicator from './ProgressIndicator.vue';
import { DatabaseIcon, VectorIcon, TrashIcon } from './icons';
import { getMessage } from '@/utils/i18n';
interface CacheEntry {
url: string;
size: number;
sizeMB: number;
timestamp: number;
age: string;
expired: boolean;
}
interface CacheStats {
totalSize: number;
totalSizeMB: number;
entryCount: number;
entries: CacheEntry[];
}
interface Props {
cacheStats: CacheStats | null;
isManagingCache: boolean;
}
interface Emits {
(e: 'cleanup-cache'): void;
(e: 'clear-all-cache'): void;
}
defineProps<Props>();
defineEmits<Emits>();
const getModelNameFromUrl = (url: string) => {
// Extract model name from HuggingFace URL
const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
return match[1];
}
return url.split('/').pop() || url;
};
</script>
<style scoped>
.model-cache-section {
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.stats-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 16px;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.stats-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stats-icon {
padding: 8px;
border-radius: 8px;
width: 36px;
height: 36px;
}
.stats-icon.orange {
background: #fed7aa;
color: #ea580c;
}
.stats-icon.purple {
background: #e9d5ff;
color: #9333ea;
}
.stats-value {
font-size: 30px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.cache-details {
margin-bottom: 16px;
}
.cache-details-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
}
.cache-entries {
display: flex;
flex-direction: column;
gap: 8px;
}
.cache-entry {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
}
.entry-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.entry-url {
font-weight: 500;
color: #1f2937;
font-size: 14px;
}
.entry-details {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
}
.entry-size {
background: #dbeafe;
color: #1e40af;
padding: 2px 6px;
border-radius: 4px;
}
.entry-age {
color: #6b7280;
}
.entry-expired {
background: #fee2e2;
color: #dc2626;
padding: 2px 6px;
border-radius: 4px;
}
.no-cache,
.loading-cache {
text-align: center;
color: #6b7280;
padding: 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 16px;
}
.cache-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.secondary-button {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: center;
user-select: none;
cursor: pointer;
}
.secondary-button:hover:not(:disabled) {
background: #e2e8f0;
border-color: #94a3b8;
}
.secondary-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.danger-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: white;
border: 1px solid #d1d5db;
color: #374151;
font-weight: 600;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.danger-button:hover:not(:disabled) {
border-color: #ef4444;
color: #dc2626;
}
.danger-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Design Tokens Types (Phase 5.4)
*
* Type definitions for runtime CSS custom properties (design tokens).
*
* Scope:
* - Phase 5.4: Runtime CSS variables only (no server-side token scanning)
* - Future phases may extend to support project-level tokens from config files
*/
// =============================================================================
// Core Identifiers
// =============================================================================
/**
* CSS custom property name (must start with `--`).
* Example: '--color-primary', '--spacing-md'
*/
export type CssVarName = `--${string}`;
/**
* Root key for caching token indices.
* Uses Document or ShadowRoot as WeakMap keys.
*/
export type RootCacheKey = Document | ShadowRoot;
/** Type of root context */
export type RootType = 'document' | 'shadow';
// =============================================================================
// Token Classification
// =============================================================================
/**
* Token value type classification.
* Used for filtering and UI grouping.
*/
export type TokenKind =
| 'color' // Color values (hex, rgb, hsl, etc.)
| 'length' // Length values (px, rem, em, %, etc.)
| 'number' // Unitless numbers
| 'shadow' // Box/text shadow values
| 'font' // Font family or font-related values
| 'unknown'; // Unable to classify
// =============================================================================
// Declaration Source
// =============================================================================
/** Reference to a stylesheet */
export interface StyleSheetRef {
/** Full URL if available */
url?: string;
/** Human-readable label (filename or element description) */
label: string;
}
/** Where the token declaration originated */
export type TokenDeclarationOrigin = 'rule' | 'inline';
/**
* A single declaration site for a token.
* One token name can have multiple declarations across stylesheets/rules.
*/
export interface TokenDeclaration {
/** Token name (e.g., '--color-primary') */
name: CssVarName;
/** Raw declared value */
value: string;
/** Whether declared with !important */
important: boolean;
/** Origin type */
origin: TokenDeclarationOrigin;
/** Root type where declared */
rootType: RootType;
/** Source stylesheet reference */
styleSheet?: StyleSheetRef;
/** CSS selector for rule-based declarations */
selectorText?: string;
/** Source order within collection pass (ascending) */
order: number;
}
// =============================================================================
// Token Model
// =============================================================================
/**
* Design token with all known declarations.
* Aggregates declaration sites for a single token name.
*/
export interface DesignToken {
/** Token name */
name: CssVarName;
/** Best-effort value type classification */
kind: TokenKind;
/** All declaration sites in source order */
declarations: readonly TokenDeclaration[];
}
// =============================================================================
// Index and Query Results
// =============================================================================
/** Statistics from a token collection pass */
export interface TokenIndexStats {
/** Number of stylesheets scanned */
styleSheets: number;
/** Number of CSS rules processed */
rulesScanned: number;
/** Number of unique token names found */
tokens: number;
/** Total number of declaration sites */
declarations: number;
}
/**
* Root-level token index.
* Contains all token declarations found in a root's stylesheets.
*/
export interface TokenIndex {
/** Root type */
rootType: RootType;
/** Map of token name to declaration sites */
tokens: Map<CssVarName, TokenDeclaration[]>;
/** Warnings encountered during scanning */
warnings: string[];
/** Collection statistics */
stats: TokenIndexStats;
}
/**
* Token with its computed value in a specific element context.
* Used for showing available tokens when editing an element.
*/
export interface ContextToken {
/** Token definition */
token: DesignToken;
/** Computed value via getComputedStyle(element).getPropertyValue(name) */
computedValue: string;
}
/** Generic query result wrapper */
export interface TokenQueryResult<T> {
/** Result items */
tokens: readonly T[];
/** Warnings from the operation */
warnings: readonly string[];
/** Statistics */
stats: TokenIndexStats;
}
// =============================================================================
// Resolution Types
// =============================================================================
/** Parsed var() reference */
export interface CssVarReference {
/** Token name */
name: CssVarName;
/** Optional fallback value */
fallback?: string;
}
/** Token availability status */
export type TokenAvailability = 'available' | 'unset';
/** Method used to resolve token value */
export type TokenResolutionMethod =
| 'computed' // getComputedStyle().getPropertyValue()
| 'probe' // DOM probe element
| 'none'; // Not resolved
/** Token resolution result */
export interface TokenResolution {
/** Token name */
token: CssVarName;
/** Computed custom property value (may be empty if unset) */
computedValue: string;
/** Availability status */
availability: TokenAvailability;
}
/** Resolved token ready to apply to a CSS property */
export interface TokenResolvedForProperty {
/** Token name */
token: CssVarName;
/** Target CSS property */
cssProperty: string;
/** CSS value to apply (e.g., 'var(--token)' or 'var(--token, fallback)') */
cssValue: string;
/** Best-effort resolved preview value */
resolvedValue?: string;
/** Resolution method used */
method: TokenResolutionMethod;
}
// =============================================================================
// Cache Invalidation
// =============================================================================
/** Reason for cache invalidation */
export type TokenInvalidationReason =
| 'manual' // Explicitly invalidated via API
| 'head_mutation' // Document head changed (style/link added/removed)
| 'shadow_mutation' // ShadowRoot content changed
| 'ttl' // Time-to-live expired
| 'unknown';
/** Event emitted when token cache is invalidated */
export interface TokenInvalidationEvent {
/** Affected root */
root: RootCacheKey;
/** Root type */
rootType: RootType;
/** Invalidation reason */
reason: TokenInvalidationReason;
/** Timestamp */
timestamp: number;
}
/** Unsubscribe function for event listeners */
export type Unsubscribe = () => void;
```
--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/strategies/anchor-relpath.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Anchor + Relative Path Strategy
*
* This strategy generates selectors by finding a stable ancestor "anchor"
* (element with unique id or data-testid/data-qa/etc.) and building a
* relative path from that anchor to the target element.
*
* Use case: When the target element itself has no unique identifiers,
* but a nearby ancestor does.
*
* Example output: '[data-testid="card"] div > span:nth-of-type(2) > button'
* (anchor selector + descendant combinator + relative path with child combinators)
*/
import type { SelectorCandidate, SelectorStrategy, SelectorStrategyContext } from '../types';
// =============================================================================
// Constants
// =============================================================================
/** Maximum ancestor depth to search for an anchor */
const MAX_ANCHOR_DEPTH = 20;
/** Data attributes eligible for anchor selection (stable, test-friendly) */
const ANCHOR_DATA_ATTRS = [
'data-testid',
'data-test-id',
'data-test',
'data-qa',
'data-cy',
] as const;
/**
* Weight penalty for anchor-relpath candidates.
* This ensures they rank lower than direct selectors (id, testid, class)
* but higher than pure text selectors.
*/
const ANCHOR_RELPATH_WEIGHT = -10;
// =============================================================================
// Internal Helpers
// =============================================================================
function safeQuerySelector(root: ParentNode, selector: string): Element | null {
try {
return root.querySelector(selector);
} catch {
return null;
}
}
/**
* Get siblings from the appropriate parent context
*/
function getSiblings(element: Element): Element[] {
const parent = element.parentElement;
if (parent) {
return Array.from(parent.children);
}
const parentNode = element.parentNode;
if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {
return Array.from(parentNode.children);
}
return [];
}
/**
* Try to build a unique anchor selector for an element.
* Only uses stable identifiers: id or ANCHOR_DATA_ATTRS.
*/
function tryAnchorSelector(element: Element, ctx: SelectorStrategyContext): string | null {
const { helpers } = ctx;
const tag = element.tagName.toLowerCase();
// Try ID first (highest priority)
const id = element.id?.trim();
if (id) {
const idSelector = `#${helpers.cssEscape(id)}`;
if (helpers.isUnique(idSelector)) {
return idSelector;
}
}
// Try stable data attributes
for (const attr of ANCHOR_DATA_ATTRS) {
const value = element.getAttribute(attr)?.trim();
if (!value) continue;
const escaped = helpers.cssEscape(value);
// Try attribute-only selector
const attrOnly = `[${attr}="${escaped}"]`;
if (helpers.isUnique(attrOnly)) {
return attrOnly;
}
// Try with tag prefix for disambiguation
const withTag = `${tag}${attrOnly}`;
if (helpers.isUnique(withTag)) {
return withTag;
}
}
return null;
}
/**
* Build a relative path selector from an ancestor to a target element.
* Uses tag names with :nth-of-type() for disambiguation.
*
* @returns Selector string like "div > span:nth-of-type(2) > button", or null if failed
*/
function buildRelativePathSelector(
ancestor: Element,
target: Element,
root: Document | ShadowRoot,
): string | null {
const segments: string[] = [];
let current: Element | null = target;
for (let depth = 0; current && current !== ancestor && depth < MAX_ANCHOR_DEPTH; depth++) {
const tag = current.tagName.toLowerCase();
let segment = tag;
// Calculate nth-of-type index if there are siblings with same tag
const siblings = getSiblings(current);
const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName);
if (sameTagSiblings.length > 1) {
const index = sameTagSiblings.indexOf(current) + 1;
segment += `:nth-of-type(${index})`;
}
segments.unshift(segment);
// Move to parent
const parentEl: Element | null = current.parentElement;
if (!parentEl) {
// Check if we've reached the root boundary
const parentNode = current.parentNode;
if (parentNode === root) break;
break;
}
current = parentEl;
}
// Verify we reached the ancestor
if (current !== ancestor) {
return null;
}
return segments.length > 0 ? segments.join(' > ') : null;
}
/**
* Build an "anchor + relative path" selector for an element.
*
* Algorithm:
* 1. Walk up from target's parent, looking for an anchor
* 2. For each potential anchor, build the relative path
* 3. Verify the composed selector uniquely matches the target
*/
function buildAnchorRelPathSelector(element: Element, ctx: SelectorStrategyContext): string | null {
const { root } = ctx;
// Ensure root is a valid query context
if (!(root instanceof Document || root instanceof ShadowRoot)) {
return null;
}
let current: Element | null = element.parentElement;
for (let depth = 0; current && depth < MAX_ANCHOR_DEPTH; depth++) {
// Skip document root elements
const tagUpper = current.tagName.toUpperCase();
if (tagUpper === 'HTML' || tagUpper === 'BODY') {
break;
}
// Try to use this element as an anchor
const anchor = tryAnchorSelector(current, ctx);
if (!anchor) {
current = current.parentElement;
continue;
}
// Build relative path from anchor to target
const relativePath = buildRelativePathSelector(current, element, root);
if (!relativePath) {
current = current.parentElement;
continue;
}
// Compose the full selector
const composed = `${anchor} ${relativePath}`;
// Verify uniqueness
if (!ctx.helpers.isUnique(composed)) {
current = current.parentElement;
continue;
}
// Final verification: ensure we match the exact element
const found = safeQuerySelector(root, composed);
if (found === element) {
return composed;
}
current = current.parentElement;
}
return null;
}
// =============================================================================
// Strategy Export
// =============================================================================
export const anchorRelpathStrategy: SelectorStrategy = {
id: 'anchor-relpath',
generate(ctx: SelectorStrategyContext): ReadonlyArray<SelectorCandidate> {
const selector = buildAnchorRelPathSelector(ctx.element, ctx);
if (!selector) {
return [];
}
return [
{
type: 'css',
value: selector,
weight: ANCHOR_RELPATH_WEIGHT,
source: 'generated',
strategy: 'anchor-relpath',
},
];
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Quick Panel Tabs Handler
*
* Background service worker bridge for Quick Panel (content script) to:
* - Enumerate tabs for search suggestions
* - Activate a selected tab
* - Close a tab
*
* Note: Content scripts cannot access chrome.tabs.* directly.
*/
import {
BACKGROUND_MESSAGE_TYPES,
type QuickPanelActivateTabMessage,
type QuickPanelActivateTabResponse,
type QuickPanelCloseTabMessage,
type QuickPanelCloseTabResponse,
type QuickPanelTabSummary,
type QuickPanelTabsQueryMessage,
type QuickPanelTabsQueryResponse,
} from '@/common/message-types';
// ============================================================
// Constants
// ============================================================
const LOG_PREFIX = '[QuickPanelTabs]';
// ============================================================
// Helpers
// ============================================================
function isValidTabId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function isValidWindowId(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
const anyTab = tab as unknown as { lastAccessed?: unknown };
const value = anyTab.lastAccessed;
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function safeErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message || String(err);
}
return String(err);
}
/**
* Convert a chrome.tabs.Tab to our summary format.
* Returns null if tab is invalid.
*/
function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {
if (!isValidTabId(tab.id)) return null;
const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;
if (windowId === null) return null;
return {
tabId: tab.id,
windowId,
title: tab.title ?? '',
url: tab.url ?? '',
favIconUrl: tab.favIconUrl ?? undefined,
active: normalizeBoolean(tab.active),
pinned: normalizeBoolean(tab.pinned),
audible: normalizeBoolean(tab.audible),
muted: normalizeBoolean(tab.mutedInfo?.muted),
index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,
lastAccessed: getLastAccessed(tab),
};
}
// ============================================================
// Message Handlers
// ============================================================
async function handleTabsQuery(
message: QuickPanelTabsQueryMessage,
sender: chrome.runtime.MessageSender,
): Promise<QuickPanelTabsQueryResponse> {
try {
const includeAllWindows = message.payload?.includeAllWindows ?? true;
// Extract current context from sender
const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;
const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;
// Quick Panel should only be called from content scripts (which have sender.tab)
// Reject requests without valid sender tab context for security
if (!includeAllWindows && currentWindowId === null) {
return {
success: false,
error: 'Invalid request: sender tab context required for window-scoped queries',
};
}
// Build query info based on scope
const queryInfo: chrome.tabs.QueryInfo = includeAllWindows
? {}
: { windowId: currentWindowId! };
const tabs = await chrome.tabs.query(queryInfo);
// Convert to summaries, filtering out invalid tabs
const summaries: QuickPanelTabSummary[] = [];
for (const tab of tabs) {
const summary = toTabSummary(tab);
if (summary) {
summaries.push(summary);
}
}
return {
success: true,
tabs: summaries,
currentTabId,
currentWindowId,
};
} catch (err) {
console.warn(`${LOG_PREFIX} Error querying tabs:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to query tabs',
};
}
}
async function handleActivateTab(
message: QuickPanelActivateTabMessage,
): Promise<QuickPanelActivateTabResponse> {
try {
const tabId = message.payload?.tabId;
const windowId = message.payload?.windowId;
if (!isValidTabId(tabId)) {
return { success: false, error: 'Invalid tabId' };
}
// Focus the window first if provided
if (isValidWindowId(windowId)) {
try {
await chrome.windows.update(windowId, { focused: true });
} catch {
// Best-effort: tab activation may still succeed without focusing window.
}
}
// Activate the tab
await chrome.tabs.update(tabId, { active: true });
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error activating tab:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to activate tab',
};
}
}
async function handleCloseTab(
message: QuickPanelCloseTabMessage,
): Promise<QuickPanelCloseTabResponse> {
try {
const tabId = message.payload?.tabId;
if (!isValidTabId(tabId)) {
return { success: false, error: 'Invalid tabId' };
}
await chrome.tabs.remove(tabId);
return { success: true };
} catch (err) {
console.warn(`${LOG_PREFIX} Error closing tab:`, err);
return {
success: false,
error: safeErrorMessage(err) || 'Failed to close tab',
};
}
}
// ============================================================
// Initialization
// ============================================================
let initialized = false;
/**
* Initialize the Quick Panel Tabs handler.
* Safe to call multiple times - subsequent calls are no-ops.
*/
export function initQuickPanelTabsHandler(): void {
if (initialized) return;
initialized = true;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Tabs query
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {
handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);
return true; // Will respond asynchronously
}
// Tab activate
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {
handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);
return true;
}
// Tab close
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {
handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);
return true;
}
return false; // Not handled by this listener
});
console.debug(`${LOG_PREFIX} Initialized`);
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Shared selector engine types.
*
* Goals:
* - JSON-serializable (store in flows / send across message boundary)
* - Reusable from both content scripts and background
*
* Composite selector format:
* "<frameSelector> |> <innerSelector>"
* This is kept for backward compatibility with the existing recorder and
* accessibility-tree helper.
*/
export type NonEmptyArray<T> = [T, ...T[]];
export interface Point {
x: number;
y: number;
}
export type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';
export type SelectorCandidateSource = 'recorded' | 'user' | 'generated';
export interface SelectorStabilitySignals {
usesId?: boolean;
usesTestId?: boolean;
usesAria?: boolean;
usesText?: boolean;
usesNthOfType?: boolean;
usesAttributes?: boolean;
usesClass?: boolean;
}
export interface SelectorStability {
/** Stability score in range [0, 1]. Higher is more stable. */
score: number;
signals?: SelectorStabilitySignals;
note?: string;
}
export interface SelectorCandidateBase {
type: SelectorType;
/**
* Primary representation:
* - css/attr: CSS selector string
* - xpath: XPath expression string
* - text: visible text query string
* - aria: human-readable expression for debugging/UI
*/
value: string;
/** Optional user-adjustable priority. Higher wins when ordering candidates. */
weight?: number;
/** Where this candidate came from. */
source?: SelectorCandidateSource;
/** Strategy identifier that produced this candidate. */
strategy?: string;
/** Optional computed stability. */
stability?: SelectorStability;
}
export type TextMatchMode = 'exact' | 'contains';
export type SelectorCandidate =
| (SelectorCandidateBase & { type: 'css' | 'attr' })
| (SelectorCandidateBase & { type: 'xpath' })
| (SelectorCandidateBase & { type: 'text'; match?: TextMatchMode; tagNameHint?: string })
| (SelectorCandidateBase & { type: 'aria'; role?: string; name?: string });
export interface SelectorTarget {
/**
* Optional primary selector string.
* This is the fast path for locating (usually CSS). May be composite.
*/
selector?: string;
/** Ordered candidates; must be non-empty. */
candidates: NonEmptyArray<SelectorCandidate>;
/** Optional tag name hint used for text search. */
tagName?: string;
/** Optional ephemeral element ref, when available. */
ref?: string;
// --------------------------------
// Extended Locator Metadata (Phase 1.2)
// --------------------------------
// These fields are generated and carried across message/storage boundaries,
// but the background-side SelectorLocator may not fully use them until
// Phase 2 wires the DOM-side protocol (fingerprint verification, shadow traversal).
/**
* Structural fingerprint for fuzzy element matching.
* Format: "tag|id=xxx|class=a.b.c|text=xxx"
*/
fingerprint?: string;
/**
* Child-index path relative to the current root (Document/ShadowRoot).
* Used for fast element recovery when selectors fail.
*/
domPath?: number[];
/**
* Shadow host selector chain (outer -> inner).
* When present, selectors/domPath are relative to the innermost ShadowRoot.
*/
shadowHostChain?: string[];
}
/**
* SelectorTarget with required extended locator metadata.
*
* Use this type when all extended fields must be present (e.g., for reliable
* cross-session persistence or HMR recovery).
*
* Note: Phase 1.2 only guarantees generation/transport; behavioral enforcement
* (fingerprint verification, shadow traversal) depends on Phase 2 integration.
*/
export interface ExtendedSelectorTarget extends SelectorTarget {
fingerprint: string;
domPath: number[];
/** May be empty array if element is not inside Shadow DOM */
shadowHostChain: string[];
}
export interface LocatedElement {
ref: string;
center: Point;
/** Resolved frameId in the tab (when inside an iframe). */
frameId?: number;
resolvedBy: 'ref' | SelectorType;
selectorUsed?: string;
}
export interface SelectorLocateOptions {
/** Frame context for non-composite selectors (default: top frame). */
frameId?: number;
/** Whether to try resolving `target.ref` before selectors. */
preferRef?: boolean;
/** Forwarded to helper uniqueness checks. */
allowMultiple?: boolean;
/**
* Whether to verify target.fingerprint when available.
*
* Note: Phase 1.2 exposes this option but may not fully enforce it until
* the DOM-side protocol is wired (Phase 2).
*/
verifyFingerprint?: boolean;
}
// ================================
// Composite Selector Utilities
// ================================
export const COMPOSITE_SELECTOR_SEPARATOR = '|>' as const;
export interface CompositeSelectorParts {
frameSelector: string;
innerSelector: string;
}
export function splitCompositeSelector(selector: string): CompositeSelectorParts | null {
if (typeof selector !== 'string') return null;
const parts = selector
.split(COMPOSITE_SELECTOR_SEPARATOR)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length < 2) return null;
return {
frameSelector: parts[0],
innerSelector: parts.slice(1).join(` ${COMPOSITE_SELECTOR_SEPARATOR} `),
};
}
export function isCompositeSelector(selector: string): boolean {
return splitCompositeSelector(selector) !== null;
}
export function composeCompositeSelector(frameSelector: string, innerSelector: string): string {
return `${String(frameSelector).trim()} ${COMPOSITE_SELECTOR_SEPARATOR} ${String(innerSelector).trim()}`.trim();
}
// ================================
// Strategy Pattern Types
// ================================
export interface NormalizedSelectorGenerationOptions {
maxCandidates: number;
includeText: boolean;
includeAria: boolean;
includeCssUnique: boolean;
includeCssPath: boolean;
testIdAttributes: ReadonlyArray<string>;
textMaxLength: number;
textTags: ReadonlyArray<string>;
}
export interface SelectorGenerationOptions {
maxCandidates?: number;
includeText?: boolean;
includeAria?: boolean;
includeCssUnique?: boolean;
includeCssPath?: boolean;
testIdAttributes?: ReadonlyArray<string>;
textMaxLength?: number;
textTags?: ReadonlyArray<string>;
}
export interface SelectorStrategyHelpers {
cssEscape: (value: string) => string;
isUnique: (selector: string) => boolean;
safeQueryAll: (selector: string) => ReadonlyArray<Element>;
}
export interface SelectorStrategyContext {
element: Element;
root: ParentNode;
options: NormalizedSelectorGenerationOptions;
helpers: SelectorStrategyHelpers;
}
export interface SelectorStrategy {
/** Stable id used for debugging/analytics. */
id: string;
generate: (ctx: SelectorStrategyContext) => ReadonlyArray<SelectorCandidate>;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts:
--------------------------------------------------------------------------------
```typescript
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TargetLocator, SelectorCandidate } from './types';
// design note: minimal selector engine that tries ref then candidates
export interface LocatedElement {
ref?: string;
center?: { x: number; y: number };
resolvedBy?: 'ref' | SelectorCandidate['type'];
frameId?: number;
}
// Helper: decide whether selector is a composite cross-frame selector
function isCompositeSelector(sel: string): boolean {
return typeof sel === 'string' && sel.includes('|>');
}
// Helper: typed wrapper for chrome.tabs.sendMessage with optional frameId
async function sendToTab(tabId: number, message: any, frameId?: number): Promise<any> {
if (typeof frameId === 'number') {
return await chrome.tabs.sendMessage(tabId, message, { frameId });
}
return await chrome.tabs.sendMessage(tabId, message);
}
// Helper: ensure ref for a selector, handling composite selectors and mapping frameId
async function ensureRefForSelector(
tabId: number,
selector: string,
frameId?: number,
): Promise<{ ref: string; center: { x: number; y: number }; frameId?: number } | null> {
try {
let ensured: any = null;
if (isCompositeSelector(selector)) {
// Always query top for composite; helper will bridge to child and return href
ensured = await sendToTab(tabId, {
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
selector,
});
} else {
ensured = await sendToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector },
frameId,
);
}
if (!ensured || !ensured.success || !ensured.ref || !ensured.center) return null;
// Map frameId when composite via returned href
let locFrameId: number | undefined = undefined;
if (isCompositeSelector(selector) && ensured.href) {
try {
const frames = (await chrome.webNavigation.getAllFrames({ tabId })) as any[];
const match = frames?.find((f) => typeof f.url === 'string' && f.url === ensured.href);
if (match) locFrameId = match.frameId;
} catch {}
}
return { ref: ensured.ref, center: ensured.center, frameId: locFrameId };
} catch {
return null;
}
}
/**
* Try to resolve an element using ref or candidates via content scripts
*/
export async function locateElement(
tabId: number,
target: TargetLocator,
frameId?: number,
): Promise<LocatedElement | null> {
// 0) Fast path: try primary selector if provided
const primarySel = (target as any)?.selector ? String((target as any).selector).trim() : '';
if (primarySel) {
const ensured = await ensureRefForSelector(tabId, primarySel, frameId);
if (ensured) return { ...ensured, resolvedBy: 'css' };
}
// 1) Non-text candidates first for stability (css/attr/aria/xpath)
const nonText = (target.candidates || []).filter((c) => c.type !== 'text');
for (const c of nonText) {
try {
if (c.type === 'css' || c.type === 'attr') {
const ensured = await ensureRefForSelector(tabId, String(c.value || ''), frameId);
if (ensured) return { ...ensured, resolvedBy: c.type };
} else if (c.type === 'aria') {
// Minimal ARIA role+name parser like: "button[name=提交]" or "textbox[name=用户名]"
const v = String(c.value || '').trim();
const m = v.match(/^(\w+)\s*\[\s*name\s*=\s*([^\]]+)\]$/);
const role = m ? m[1] : '';
const name = m ? m[2] : '';
const cleanName = name.replace(/^['"]|['"]$/g, '');
const ariaSelectors: string[] = [];
if (role === 'textbox') {
ariaSelectors.push(
`[role="textbox"][aria-label=${JSON.stringify(cleanName)}]`,
`input[aria-label=${JSON.stringify(cleanName)}]`,
`textarea[aria-label=${JSON.stringify(cleanName)}]`,
);
} else if (role === 'button') {
ariaSelectors.push(
`[role="button"][aria-label=${JSON.stringify(cleanName)}]`,
`button[aria-label=${JSON.stringify(cleanName)}]`,
);
} else if (role === 'link') {
ariaSelectors.push(
`[role="link"][aria-label=${JSON.stringify(cleanName)}]`,
`a[aria-label=${JSON.stringify(cleanName)}]`,
);
}
if (!ariaSelectors.length && role) {
ariaSelectors.push(
`[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`,
);
}
for (const sel of ariaSelectors) {
const ensured = await sendToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector: sel } as any,
frameId,
);
if (ensured && ensured.success && ensured.ref && ensured.center) {
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };
}
}
} else if (c.type === 'xpath') {
// Minimal xpath support via document.evaluate through injected helper
const ensured = await sendToTab(
tabId,
{
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
selector: c.value,
isXPath: true,
} as any,
frameId,
);
if (ensured && ensured.success && ensured.ref && ensured.center) {
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };
}
}
} catch (e) {
// continue to next candidate
}
}
// 2) Human-intent fallback: text-based search as last resort
const textCands = (target.candidates || []).filter((c) => c.type === 'text');
const tagName = ((target as any)?.tag || '').toString();
for (const c of textCands) {
try {
const ensured = await sendToTab(
tabId,
{
action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
useText: true,
text: c.value,
tagName,
} as any,
frameId,
);
if (ensured && ensured.success && ensured.ref && ensured.center) {
return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
}
} catch {}
}
// Fallback: try ref (works when ref was produced in the same page lifecycle)
if (target.ref) {
try {
const res = await sendToTab(
tabId,
{ action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: target.ref } as any,
frameId,
);
if (res && res.success && res.center) {
return { ref: target.ref, center: res.center, resolvedBy: 'ref' };
}
} catch (e) {
// ignore
}
}
return null;
}
/**
* Ensure screenshot context hostname is still valid for coordinate-based actions
*/
// Note: screenshot hostname validation is handled elsewhere; removed legacy stub.
```