This is page 3 of 43. Use http://codebase.md/hangwin/mcp-chrome?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/element-marker/element-marker-storage.ts:
--------------------------------------------------------------------------------
```typescript
// IndexedDB storage for element markers (URL -> marked selectors)
// Uses the shared IndexedDbClient for robust transaction handling.
import { IndexedDbClient } from '@/utils/indexeddb-client';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
const DB_NAME = 'element_marker_storage';
const DB_VERSION = 1;
const STORE = 'markers';
const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
switch (oldVersion) {
case 0: {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
// Useful indexes for lookups
store.createIndex('by_host', 'host', { unique: false });
store.createIndex('by_origin', 'origin', { unique: false });
store.createIndex('by_path', 'path', { unique: false });
}
}
});
function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {
try {
const u = new URL(raw);
return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };
} catch {
return { url: raw, origin: '', host: '', path: '' };
}
}
function now(): number {
return Date.now();
}
export async function listAllMarkers(): Promise<ElementMarker[]> {
return idb.getAll<ElementMarker>(STORE);
}
export async function listMarkersForUrl(url: string): Promise<ElementMarker[]> {
const { origin, path, host } = normalizeUrl(url);
const all = await idb.getAll<ElementMarker>(STORE);
// Simple matching policy:
// - exact: origin + path must match exactly
// - prefix: origin matches and marker.path is a prefix of current path
// - host: host matches regardless of path
return all.filter((m) => {
if (!m) return false;
if (m.matchType === 'exact') return m.origin === origin && m.path === path;
if (m.matchType === 'host') return !!m.host && m.host === host;
// default 'prefix'
return m.origin === origin && (m.path ? path.startsWith(m.path) : true);
});
}
export async function saveMarker(req: UpsertMarkerRequest): Promise<ElementMarker> {
const { url: rawUrl, selector } = req;
if (!rawUrl || !selector) throw new Error('url and selector are required');
const { url, origin, host, path } = normalizeUrl(rawUrl);
const ts = now();
const marker: ElementMarker = {
id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),
url,
origin,
host,
path,
matchType: req.matchType || 'prefix',
name: req.name || selector,
selector,
selectorType: req.selectorType || 'css',
listMode: req.listMode || false,
action: req.action || 'custom',
createdAt: ts,
updatedAt: ts,
};
await idb.put<ElementMarker>(STORE, marker);
return marker;
}
export async function updateMarker(marker: ElementMarker): Promise<void> {
const existing = await idb.get<ElementMarker>(STORE, marker.id);
if (!existing) throw new Error('marker not found');
// Preserve createdAt from existing record, only update updatedAt
const updated: ElementMarker = {
...marker,
createdAt: existing.createdAt, // Never overwrite createdAt
updatedAt: now(),
};
await idb.put<ElementMarker>(STORE, updated);
}
export async function deleteMarker(id: string): Promise<void> {
await idb.delete(STORE, id);
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts:
--------------------------------------------------------------------------------
```typescript
// engine/policies/wait.ts — wrappers around rr-utils navigation/network waits
// Keep logic centralized to avoid duplication in schedulers and nodes
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils';
export async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) {
await rrWaitForNavigation(timeoutMs, prevUrl);
}
export async function ensureReadPageIfWeb() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tabs?.[0]?.url || '';
if (/^(https?:|file:)/i.test(url)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
}
export async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number) {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') return;
const sniffMs = 350;
const startedAt = Date.now();
let seen = false;
await new Promise<void>((resolve) => {
let timer: any = null;
const cleanup = () => {
try {
chrome.webNavigation.onCommitted.removeListener(onCommitted);
} catch {}
try {
chrome.webNavigation.onCompleted.removeListener(onCompleted);
} catch {}
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(
onHistoryStateUpdated,
);
} catch {}
try {
chrome.tabs.onUpdated.removeListener(onUpdated);
} catch {}
if (timer) {
try {
clearTimeout(timer);
} catch {}
}
};
const finish = async () => {
cleanup();
if (seen) {
try {
await rrWaitForNavigation(
prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,
prevUrl,
);
} catch {}
}
resolve();
};
const mark = () => {
seen = true;
};
const onCommitted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onCompleted = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onHistoryStateUpdated = (d: any) => {
if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();
};
const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => {
if (updatedId !== tabId) return;
if (change.status === 'loading') mark();
if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark();
};
chrome.webNavigation.onCommitted.addListener(onCommitted);
chrome.webNavigation.onCompleted.addListener(onCompleted);
try {
(chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);
} catch {}
chrome.tabs.onUpdated.addListener(onUpdated);
timer = setTimeout(finish, sniffMs);
});
} catch {}
}
export { waitForNetworkIdle };
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Screenshot Action Handler
*
* Captures screenshots and optionally stores base64 data in variables.
* Supports full page, selector-based, and viewport screenshots.
*/
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 { resolveString } from './common';
/** Extract text content from tool result */
function extractToolText(result: unknown): string | undefined {
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
const text = content?.find((c) => c?.type === 'text' && typeof c.text === 'string')?.text;
return typeof text === 'string' && text.trim() ? text : undefined;
}
export const screenshotHandler: ActionHandler<'screenshot'> = {
type: 'screenshot',
validate: (action) => {
const saveAs = action.params.saveAs;
if (saveAs !== undefined && (!saveAs || String(saveAs).trim().length === 0)) {
return invalid('saveAs must be a non-empty variable name when provided');
}
return ok();
},
describe: (action) => {
if (action.params.fullPage) return 'Screenshot (full page)';
if (typeof action.params.selector === 'string') {
const sel =
action.params.selector.length > 30
? action.params.selector.slice(0, 30) + '...'
: action.params.selector;
return `Screenshot: ${sel}`;
}
if (action.params.selector) return 'Screenshot (dynamic selector)';
return 'Screenshot';
},
run: async (ctx, action) => {
const tabId = ctx.tabId;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found for screenshot action');
}
// Resolve optional selector
let selector: string | undefined;
if (action.params.selector !== undefined) {
const resolved = resolveString(action.params.selector, ctx.vars);
if (!resolved.ok) return failed('VALIDATION_ERROR', resolved.error);
const s = resolved.value.trim();
if (s) selector = s;
}
// Call screenshot tool
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.SCREENSHOT,
args: {
name: 'workflow',
storeBase64: true,
fullPage: action.params.fullPage === true,
selector,
tabId,
},
});
if ((res as { isError?: boolean })?.isError) {
return failed('UNKNOWN', extractToolText(res) || 'Screenshot failed');
}
// Parse response
const text = extractToolText(res);
if (!text) {
return failed('UNKNOWN', 'Screenshot tool returned an empty response');
}
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
return failed('UNKNOWN', 'Screenshot tool returned invalid JSON');
}
const base64Data = (payload as { base64Data?: unknown })?.base64Data;
if (typeof base64Data !== 'string' || base64Data.length === 0) {
return failed('UNKNOWN', 'Screenshot tool returned empty base64Data');
}
// Store in variables if saveAs specified
if (action.params.saveAs) {
ctx.vars[action.params.saveAs] = base64Data;
}
return { status: 'success', output: { base64Data } };
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/cdp-session-manager.ts:
--------------------------------------------------------------------------------
```typescript
import { TOOL_NAMES } from 'chrome-mcp-shared';
type OwnerTag = string;
interface TabSessionState {
refCount: number;
owners: Set<OwnerTag>;
attachedByUs: boolean;
}
const DEBUGGER_PROTOCOL_VERSION = '1.3';
class CDPSessionManager {
private sessions = new Map<number, TabSessionState>();
private getState(tabId: number): TabSessionState | undefined {
return this.sessions.get(tabId);
}
private setState(tabId: number, state: TabSessionState) {
this.sessions.set(tabId, state);
}
async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
const state = this.getState(tabId);
if (state && state.attachedByUs) {
state.refCount += 1;
state.owners.add(owner);
return;
}
// Check existing attachments
const targets = await chrome.debugger.getTargets();
const existing = targets.find((t) => t.tabId === tabId && t.attached);
if (existing) {
if (existing.extensionId === chrome.runtime.id) {
// Already attached by us (e.g., previous tool). Adopt and refcount.
this.setState(tabId, {
refCount: state ? state.refCount + 1 : 1,
owners: new Set([...(state?.owners || []), owner]),
attachedByUs: true,
});
return;
}
// Another client (DevTools/other extension) is attached
throw new Error(
`Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`,
);
}
// Attach freshly
await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true });
}
async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {
const state = this.getState(tabId);
if (!state) return; // Nothing to do
// Update ownership/refcount
if (state.owners.has(owner)) state.owners.delete(owner);
state.refCount = Math.max(0, state.refCount - 1);
if (state.refCount > 0) {
// Still in use by other owners
return;
}
// We are the last owner
try {
if (state.attachedByUs) {
await chrome.debugger.detach({ tabId });
}
} catch (e) {
// Best-effort detach; ignore
} finally {
this.sessions.delete(tabId);
}
}
/**
* Convenience wrapper: ensures attach before fn, and balanced detach after.
*/
async withSession<T>(tabId: number, owner: OwnerTag, fn: () => Promise<T>): Promise<T> {
await this.attach(tabId, owner);
try {
return await fn();
} finally {
await this.detach(tabId, owner);
}
}
/**
* Send a CDP command. Requires that this manager has attached to the tab.
* If not attached by us, will attempt a one-shot attach around the call.
*/
async sendCommand<T = any>(tabId: number, method: string, params?: object): Promise<T> {
const state = this.getState(tabId);
if (state && state.attachedByUs) {
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
}
// Fallback: temporary session
return await this.withSession<T>(tabId, `send:${method}`, async () => {
return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;
});
}
}
export const cdpSessionManager = new CDPSessionManager();
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Navigate Action Handler
*
* Handles page navigation actions:
* - Navigate to URL
* - Page refresh
* - Wait for navigation completion
*/
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { ENGINE_CONSTANTS } from '../../engine/constants';
import { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, readTabUrl, resolveString } from './common';
export const navigateHandler: ActionHandler<'navigate'> = {
type: 'navigate',
validate: (action) => {
const hasRefresh = action.params.refresh === true;
const hasUrl = action.params.url !== undefined;
return hasRefresh || hasUrl ? ok() : invalid('Missing url or refresh parameter');
},
describe: (action) => {
if (action.params.refresh) return 'Refresh page';
const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';
return `Navigate to ${url}`;
},
run: async (ctx, action) => {
const vars = ctx.vars;
const tabId = ctx.tabId;
// Check if StepRunner owns nav-wait (skip internal nav-wait logic)
const skipNavWait = ctx.execution?.skipNavWait === true;
if (typeof tabId !== 'number') {
return failed('TAB_NOT_FOUND', 'No active tab found');
}
// Only read beforeUrl and calculate waitMs if we need to do nav-wait
const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);
const waitMs = skipNavWait
? 0
: clampInt(
action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,
0,
ENGINE_CONSTANTS.MAX_WAIT_MS,
);
// Handle page refresh
if (action.params.refresh) {
const result = await handleCallTool({
name: TOOL_NAMES.BROWSER.NAVIGATE,
args: { refresh: true, tabId },
});
if ((result as { isError?: boolean })?.isError) {
const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || 'Page refresh failed';
return failed('NAVIGATION_FAILED', errorMsg);
}
// Skip nav-wait if StepRunner handles it
if (!skipNavWait) {
await waitForNavigationDone(beforeUrl, waitMs);
await ensureReadPageIfWeb();
}
return { status: 'success' };
}
// Handle URL navigation
const urlResolved = resolveString(action.params.url, vars);
if (!urlResolved.ok) {
return failed('VALIDATION_ERROR', urlResolved.error);
}
const url = urlResolved.value.trim();
if (!url) {
return failed('VALIDATION_ERROR', 'URL is empty');
}
const result = await handleCallTool({
name: TOOL_NAMES.BROWSER.NAVIGATE,
args: { url, tabId },
});
if ((result as { isError?: boolean })?.isError) {
const errorContent = (result as { content?: Array<{ text?: string }> })?.content;
const errorMsg = errorContent?.[0]?.text || `Navigation to ${url} failed`;
return failed('NAVIGATION_FAILED', errorMsg);
}
// Skip nav-wait if StepRunner handles it
if (!skipNavWait) {
await waitForNavigationDone(beforeUrl, waitMs);
await ensureReadPageIfWeb();
}
return { status: 'success' };
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts:
--------------------------------------------------------------------------------
```typescript
// after-script-queue.ts — queue + executor for deferred after-scripts
// Notes:
// - Executes user-provided code in the specified world (ISOLATED by default)
// - Clears queue before execution to avoid leaks; re-queues remainder on failure
// - Logs warnings instead of throwing to keep the main engine resilient
import type { StepScript } from '../../types';
import type { ExecCtx } from '../../nodes';
import { RunLogger } from '../logging/run-logger';
import { applyAssign } from '../../rr-utils';
export class AfterScriptQueue {
private queue: StepScript[] = [];
constructor(private logger: RunLogger) {}
enqueue(script: StepScript) {
this.queue.push(script);
}
size() {
return this.queue.length;
}
async flush(ctx: ExecCtx, vars: Record<string, any>) {
if (this.queue.length === 0) return;
const scriptsToFlush = this.queue.splice(0, this.queue.length);
for (let i = 0; i < scriptsToFlush.length; i++) {
const s = scriptsToFlush[i]!;
const tScript = Date.now();
const world = (s as any).world || 'ISOLATED';
const code = String((s as any).code || '');
if (!code.trim()) {
this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
continue;
}
try {
// Warn on obviously dangerous constructs; not a sandbox, just visibility.
const dangerous =
/[;{}]|\b(function|=>|while|for|class|globalThis|window|self|this|constructor|__proto__|prototype|eval|Function|import|require|XMLHttpRequest|fetch|chrome)\b/;
if (dangerous.test(code)) {
this.logger.push({
stepId: s.id,
status: 'warning',
message: 'Script contains potentially unsafe tokens; executed in isolated world',
});
}
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') throw new Error('Active tab not found');
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: (userCode: string) => {
try {
return (0, eval)(userCode);
} catch (e) {
return { __error: true, message: String(e) } as any;
}
},
args: [code],
world: world as any,
} as any);
if ((result as any)?.__error) {
this.logger.push({
stepId: s.id,
status: 'warning',
message: `After-script error: ${(result as any).message || 'unknown'}`,
});
}
const value = (result as any)?.__error ? null : result;
if ((s as any).saveAs) (vars as any)[(s as any).saveAs] = value;
if ((s as any).assign && typeof (s as any).assign === 'object')
applyAssign(vars, value, (s as any).assign);
} catch (e: any) {
// Re-queue remaining and stop flush cycle for now
const remaining = scriptsToFlush.slice(i + 1);
if (remaining.length) this.queue.unshift(...remaining);
this.logger.push({
stepId: s.id,
status: 'warning',
message: `After-script execution failed: ${e?.message || String(e)}`,
});
break;
}
this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });
}
}
}
```
--------------------------------------------------------------------------------
/app/native-server/src/trace-analyzer.ts:
--------------------------------------------------------------------------------
```typescript
import * as fs from 'fs';
// Import DevTools trace engine and formatters from chrome-devtools-frontend
// We intentionally use deep imports to match the package structure.
// These modules are ESM and require NodeNext module resolution.
// Types are loosely typed to minimize coupling with DevTools internals.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as TraceEngine from 'chrome-devtools-frontend/front_end/models/trace/trace.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { PerformanceTraceFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { PerformanceInsightFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { AgentFocus } from 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';
const engine = TraceEngine.TraceModel.Model.createWithAllHandlers();
function readJsonFile(path: string): any {
const text = fs.readFileSync(path, 'utf-8');
return JSON.parse(text);
}
export async function parseTrace(json: any): Promise<{
parsedTrace: any;
insights: any | null;
}> {
engine.resetProcessor();
const events = Array.isArray(json) ? json : json.traceEvents;
if (!events || !Array.isArray(events)) {
throw new Error('Invalid trace format: expected array or {traceEvents: []}');
}
await engine.parse(events);
const parsedTrace = engine.parsedTrace();
const insights = parsedTrace?.insights ?? null;
if (!parsedTrace) throw new Error('No parsed trace returned by engine');
return { parsedTrace, insights };
}
export function getTraceSummary(parsedTrace: any): string {
const focus = AgentFocus.fromParsedTrace(parsedTrace);
const formatter = new PerformanceTraceFormatter(focus);
return formatter.formatTraceSummary();
}
export function getInsightText(parsedTrace: any, insights: any, insightName: string): string {
if (!insights) throw new Error('No insights available for this trace');
const mainNavId = parsedTrace.data?.Meta?.mainFrameNavigations?.at(0)?.args?.data?.navigationId;
const NO_NAV = TraceEngine.Types.Events.NO_NAVIGATION;
const set = insights.get(mainNavId ?? NO_NAV);
if (!set) throw new Error('No insights for selected navigation');
const model = set.model || {};
if (!(insightName in model)) throw new Error(`Insight not found: ${insightName}`);
const formatter = new PerformanceInsightFormatter(
AgentFocus.fromParsedTrace(parsedTrace),
model[insightName],
);
return formatter.formatInsight();
}
export async function analyzeTraceFile(
filePath: string,
insightName?: string,
): Promise<{
summary: string;
insight?: string;
}> {
const json = readJsonFile(filePath);
const { parsedTrace, insights } = await parseTrace(json);
const summary = getTraceSummary(parsedTrace);
if (insightName) {
try {
const insight = getInsightText(parsedTrace, insights, insightName);
return { summary, insight };
} catch {
// If requested insight missing, still return summary
return { summary };
}
}
return { summary };
}
export default { analyzeTraceFile };
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/quick-panel.content.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Quick Panel Content Script
*
* This content script manages the Quick Panel AI Chat feature on web pages.
* It responds to:
* - Background messages (toggle_quick_panel from keyboard shortcut)
* - Direct programmatic calls
*
* The Quick Panel provides a floating AI chat interface that:
* - Uses Shadow DOM for style isolation
* - Streams AI responses in real-time
* - Supports keyboard shortcuts (Enter to send, Esc to close)
* - Collects page context (URL, selection) automatically
*/
import { createQuickPanelController, type QuickPanelController } from '@/shared/quick-panel';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_idle',
main() {
console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href);
let controller: QuickPanelController | null = null;
/**
* Ensure controller is initialized (lazy initialization)
*/
function ensureController(): QuickPanelController {
if (!controller) {
controller = createQuickPanelController({
title: 'Agent',
subtitle: 'Quick Panel',
placeholder: 'Ask about this page...',
});
}
return controller;
}
/**
* Handle messages from background script
*/
function handleMessage(
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response?: unknown) => void,
): boolean | void {
const msg = message as { action?: string } | undefined;
if (msg?.action === 'toggle_quick_panel') {
console.log('[QuickPanelContentScript] Received toggle_quick_panel message');
try {
const ctrl = ensureController();
ctrl.toggle();
const visible = ctrl.isVisible();
console.log('[QuickPanelContentScript] Toggle completed, visible:', visible);
sendResponse({ success: true, visible });
} catch (err) {
console.error('[QuickPanelContentScript] Toggle error:', err);
sendResponse({ success: false, error: String(err) });
}
return true; // Async response
}
if (msg?.action === 'show_quick_panel') {
try {
const ctrl = ensureController();
ctrl.show();
sendResponse({ success: true });
} catch (err) {
console.error('[QuickPanelContentScript] Show error:', err);
sendResponse({ success: false, error: String(err) });
}
return true;
}
if (msg?.action === 'hide_quick_panel') {
try {
if (controller) {
controller.hide();
}
sendResponse({ success: true });
} catch (err) {
console.error('[QuickPanelContentScript] Hide error:', err);
sendResponse({ success: false, error: String(err) });
}
return true;
}
if (msg?.action === 'get_quick_panel_status') {
sendResponse({
success: true,
visible: controller?.isVisible() ?? false,
initialized: controller !== null,
});
return true;
}
// Not handled
return false;
}
// Register message listener
chrome.runtime.onMessage.addListener(handleMessage);
// Cleanup on page unload
window.addEventListener('unload', () => {
chrome.runtime.onMessage.removeListener(handleMessage);
if (controller) {
controller.dispose();
controller = null;
}
});
},
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts:
--------------------------------------------------------------------------------
```typescript
// node-util.ts - shared UI helpers for node components
// Note: comments in English
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
import { summarizeNode as summarize } from '../../model/transforms';
import ILucideMousePointerClick from '~icons/lucide/mouse-pointer-click';
import ILucideEdit3 from '~icons/lucide/edit-3';
import ILucideKeyboard from '~icons/lucide/keyboard';
import ILucideCompass from '~icons/lucide/compass';
import ILucideGlobe from '~icons/lucide/globe';
import ILucideFileCode2 from '~icons/lucide/file-code-2';
import ILucideScan from '~icons/lucide/scan';
import ILucideHourglass from '~icons/lucide/hourglass';
import ILucideCheckCircle2 from '~icons/lucide/check-circle-2';
import ILucideGitBranch from '~icons/lucide/git-branch';
import ILucideRepeat from '~icons/lucide/repeat';
import ILucideRefreshCcw from '~icons/lucide/refresh-ccw';
import ILucideSquare from '~icons/lucide/square';
import ILucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import ILucideX from '~icons/lucide/x';
import ILucideZap from '~icons/lucide/zap';
import ILucideCamera from '~icons/lucide/camera';
import ILucideBell from '~icons/lucide/bell';
import ILucideWrench from '~icons/lucide/wrench';
import ILucideFrame from '~icons/lucide/frame';
import ILucideDownload from '~icons/lucide/download';
import ILucideArrowUpDown from '~icons/lucide/arrow-up-down';
import ILucideMoveVertical from '~icons/lucide/move-vertical';
export function iconComp(t?: string) {
switch (t) {
case 'trigger':
return ILucideZap;
case 'click':
case 'dblclick':
return ILucideMousePointerClick;
case 'fill':
return ILucideEdit3;
case 'drag':
return ILucideArrowUpDown;
case 'scroll':
return ILucideMoveVertical;
case 'key':
return ILucideKeyboard;
case 'navigate':
return ILucideCompass;
case 'http':
return ILucideGlobe;
case 'script':
return ILucideFileCode2;
case 'screenshot':
return ILucideCamera;
case 'triggerEvent':
return ILucideBell;
case 'setAttribute':
return ILucideWrench;
case 'loopElements':
return ILucideRepeat;
case 'switchFrame':
return ILucideFrame;
case 'handleDownload':
return ILucideDownload;
case 'extract':
return ILucideScan;
case 'wait':
return ILucideHourglass;
case 'assert':
return ILucideCheckCircle2;
case 'if':
return ILucideGitBranch;
case 'foreach':
return ILucideRepeat;
case 'while':
return ILucideRefreshCcw;
case 'openTab':
return ILucideSquare;
case 'switchTab':
return ILucideArrowLeftRight;
case 'closeTab':
return ILucideX;
case 'delay':
return ILucideHourglass;
default:
return ILucideSquare;
}
}
export function getTypeLabel(type?: string) {
const labels: Record<string, string> = {
trigger: '触发器',
click: '点击',
fill: '填充',
navigate: '导航',
wait: '等待',
extract: '提取',
http: 'HTTP',
script: '脚本',
if: '条件',
foreach: '循环',
assert: '断言',
key: '键盘',
drag: '拖拽',
dblclick: '双击',
openTab: '打开标签',
switchTab: '切换标签',
closeTab: '关闭标签',
delay: '延迟',
scroll: '滚动',
while: '循环',
};
return labels[String(type || '')] || type || '';
}
export function nodeSubtitle(node?: NodeBase | null): string {
if (!node) return '';
const summary = summarize(node);
if (!summary) return node.type || '';
return summary.length > 40 ? summary.slice(0, 40) + '...' : summary;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview FlowV3 持久化
* @description 实现 Flow 的 CRUD 操作
*/
import type { FlowId } from '../domain/ids';
import type { FlowV3 } from '../domain/flow';
import { FLOW_SCHEMA_VERSION } from '../domain/flow';
import { RR_ERROR_CODES, createRRError } from '../domain/errors';
import type { FlowsStore } from '../engine/storage/storage-port';
import { RR_V3_STORES, withTransaction } from './db';
/**
* 校验 Flow 结构
*/
function validateFlow(flow: FlowV3): void {
// 校验 schema 版本
if (flow.schemaVersion !== FLOW_SCHEMA_VERSION) {
throw createRRError(
RR_ERROR_CODES.VALIDATION_ERROR,
`Invalid schema version: expected ${FLOW_SCHEMA_VERSION}, got ${flow.schemaVersion}`,
);
}
// 校验必填字段
if (!flow.id) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow id is required');
}
if (!flow.name) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow name is required');
}
if (!flow.entryNodeId) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow entryNodeId is required');
}
// 校验 entryNodeId 存在
const nodeIds = new Set(flow.nodes.map((n) => n.id));
if (!nodeIds.has(flow.entryNodeId)) {
throw createRRError(
RR_ERROR_CODES.VALIDATION_ERROR,
`Entry node "${flow.entryNodeId}" does not exist in flow`,
);
}
// 校验边引用
for (const edge of flow.edges) {
if (!nodeIds.has(edge.from)) {
throw createRRError(
RR_ERROR_CODES.VALIDATION_ERROR,
`Edge "${edge.id}" references non-existent source node "${edge.from}"`,
);
}
if (!nodeIds.has(edge.to)) {
throw createRRError(
RR_ERROR_CODES.VALIDATION_ERROR,
`Edge "${edge.id}" references non-existent target node "${edge.to}"`,
);
}
}
}
/**
* 创建 FlowsStore 实现
*/
export function createFlowsStore(): FlowsStore {
return {
async list(): Promise<FlowV3[]> {
return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
const store = stores[RR_V3_STORES.FLOWS];
return new Promise<FlowV3[]>((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result as FlowV3[]);
request.onerror = () => reject(request.error);
});
});
},
async get(id: FlowId): Promise<FlowV3 | null> {
return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
const store = stores[RR_V3_STORES.FLOWS];
return new Promise<FlowV3 | null>((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve((request.result as FlowV3) ?? null);
request.onerror = () => reject(request.error);
});
});
},
async save(flow: FlowV3): Promise<void> {
// 校验
validateFlow(flow);
return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
const store = stores[RR_V3_STORES.FLOWS];
return new Promise<void>((resolve, reject) => {
const request = store.put(flow);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
},
async delete(id: FlowId): Promise<void> {
return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
const store = stores[RR_V3_STORES.FLOWS];
return new Promise<void>((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/quick-panel/commands.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Quick Panel Commands Handler
*
* Handles keyboard shortcuts for Quick Panel functionality.
* Listens for the 'toggle_quick_panel' command and sends toggle message
* to the content script in the active tab.
*/
// ============================================================
// Constants
// ============================================================
const COMMAND_KEY = 'toggle_quick_panel';
const LOG_PREFIX = '[QuickPanelCommands]';
// ============================================================
// Helpers
// ============================================================
/**
* Get the ID of the currently active tab
*/
async function getActiveTabId(): Promise<number | null> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab?.id ?? null;
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);
return null;
}
}
/**
* Check if a tab can receive content scripts
*/
function isValidTabUrl(url?: string): boolean {
if (!url) return false;
// Cannot inject into browser internal pages
const invalidPrefixes = [
'chrome://',
'chrome-extension://',
'edge://',
'about:',
'moz-extension://',
'devtools://',
'view-source:',
'data:',
// 'file://',
];
return !invalidPrefixes.some((prefix) => url.startsWith(prefix));
}
// ============================================================
// Main Handler
// ============================================================
/**
* Toggle Quick Panel in the active tab
*/
async function toggleQuickPanelInActiveTab(): Promise<void> {
const tabId = await getActiveTabId();
if (tabId === null) {
console.warn(`${LOG_PREFIX} No active tab found`);
return;
}
// Get tab info to check URL validity
try {
const tab = await chrome.tabs.get(tabId);
if (!isValidTabUrl(tab.url)) {
console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);
return;
}
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);
return;
}
// Send toggle message to content script
try {
const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });
if (response?.success) {
console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);
} else {
console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);
}
} catch (err) {
// Content script may not be loaded yet; this is expected on some pages
console.warn(
`${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,
err,
);
}
}
// ============================================================
// Initialization
// ============================================================
/**
* Initialize Quick Panel keyboard command listener
*/
export function initQuickPanelCommands(): void {
console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
chrome.commands.onCommand.addListener(async (command) => {
console.log(`${LOG_PREFIX} onCommand received:`, command);
if (command !== COMMAND_KEY) {
console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
return;
}
console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
try {
await toggleQuickPanelInActiveTab();
console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
} catch (err) {
console.error(`${LOG_PREFIX} Command handler error:`, err);
}
});
console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview RunRecordV3 持久化
* @description 实现 Run 记录的 CRUD 操作
*/
import type { RunId } from '../domain/ids';
import type { RunRecordV3 } from '../domain/events';
import { RUN_SCHEMA_VERSION } from '../domain/events';
import { RR_ERROR_CODES, createRRError } from '../domain/errors';
import type { RunsStore } from '../engine/storage/storage-port';
import { RR_V3_STORES, withTransaction } from './db';
/**
* 校验 Run 记录结构
*/
function validateRunRecord(record: RunRecordV3): void {
// 校验 schema 版本
if (record.schemaVersion !== RUN_SCHEMA_VERSION) {
throw createRRError(
RR_ERROR_CODES.VALIDATION_ERROR,
`Invalid schema version: expected ${RUN_SCHEMA_VERSION}, got ${record.schemaVersion}`,
);
}
// 校验必填字段
if (!record.id) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run id is required');
}
if (!record.flowId) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run flowId is required');
}
if (!record.status) {
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run status is required');
}
}
/**
* 创建 RunsStore 实现
*/
export function createRunsStore(): RunsStore {
return {
async list(): Promise<RunRecordV3[]> {
return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {
const store = stores[RR_V3_STORES.RUNS];
return new Promise<RunRecordV3[]>((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result as RunRecordV3[]);
request.onerror = () => reject(request.error);
});
});
},
async get(id: RunId): Promise<RunRecordV3 | null> {
return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {
const store = stores[RR_V3_STORES.RUNS];
return new Promise<RunRecordV3 | null>((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);
request.onerror = () => reject(request.error);
});
});
},
async save(record: RunRecordV3): Promise<void> {
// 校验
validateRunRecord(record);
return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {
const store = stores[RR_V3_STORES.RUNS];
return new Promise<void>((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
},
async patch(id: RunId, patch: Partial<RunRecordV3>): Promise<void> {
return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {
const store = stores[RR_V3_STORES.RUNS];
// 先读取现有记录
const existing = await new Promise<RunRecordV3 | null>((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);
request.onerror = () => reject(request.error);
});
if (!existing) {
throw createRRError(RR_ERROR_CODES.INTERNAL, `Run "${id}" not found`);
}
// 合并并更新
const updated: RunRecordV3 = {
...existing,
...patch,
id: existing.id, // 确保 id 不变
schemaVersion: existing.schemaVersion, // 确保版本不变
updatedAt: Date.now(),
};
return new Promise<void>((resolve, reject) => {
const request = store.put(updated);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
});
},
};
}
```
--------------------------------------------------------------------------------
/app/native-server/src/mcp/mcp-server-stdio.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
CallToolRequestSchema,
CallToolResult,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ListPromptsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { TOOL_SCHEMAS } from 'chrome-mcp-shared';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import * as fs from 'fs';
import * as path from 'path';
let stdioMcpServer: Server | null = null;
let mcpClient: Client | null = null;
// Read configuration from stdio-config.json
const loadConfig = () => {
try {
const configPath = path.join(__dirname, 'stdio-config.json');
const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
console.error('Failed to load stdio-config.json:', error);
throw new Error('Configuration file stdio-config.json not found or invalid');
}
};
export const getStdioMcpServer = () => {
if (stdioMcpServer) {
return stdioMcpServer;
}
stdioMcpServer = new Server(
{
name: 'StdioChromeMcpServer',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
},
);
setupTools(stdioMcpServer);
return stdioMcpServer;
};
export const ensureMcpClient = async () => {
try {
if (mcpClient) {
const pingResult = await mcpClient.ping();
if (pingResult) {
return mcpClient;
}
}
const config = loadConfig();
mcpClient = new Client({ name: 'Mcp Chrome Proxy', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(config.url), {});
await mcpClient.connect(transport);
return mcpClient;
} catch (error) {
mcpClient?.close();
mcpClient = null;
console.error('Failed to connect to MCP server:', error);
}
};
export const setupTools = (server: Server) => {
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) =>
handleToolCall(request.params.name, request.params.arguments || {}),
);
// List resources handler - REQUIRED BY MCP PROTOCOL
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
// List prompts handler - REQUIRED BY MCP PROTOCOL
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
};
const handleToolCall = async (name: string, args: any): Promise<CallToolResult> => {
try {
const client = await ensureMcpClient();
if (!client) {
throw new Error('Failed to connect to MCP server');
}
// Use a sane default of 2 minutes; the previous value mistakenly used 2*6*1000 (12s)
const DEFAULT_CALL_TIMEOUT_MS = 2 * 60 * 1000;
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: DEFAULT_CALL_TIMEOUT_MS,
});
return result as CallToolResult;
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error calling tool: ${error.message}`,
},
],
isError: true,
};
}
};
async function main() {
const transport = new StdioServerTransport();
await getStdioMcpServer().connect(transport);
}
main().catch((error) => {
console.error('Fatal error Chrome MCP Server main():', error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts:
--------------------------------------------------------------------------------
```typescript
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const executeFlowNode: NodeRuntime<any> = {
validate: (step) => {
const s: any = step;
const ok = typeof s.flowId === 'string' && !!s.flowId;
return ok ? { ok } : { ok, errors: ['需提供 flowId'] };
},
run: async (ctx: ExecCtx, step) => {
const s: any = step;
const { getFlow } = await import('../flow-store');
const flow = await getFlow(String(s.flowId));
if (!flow) throw new Error('referenced flow not found');
const inline = s.inline !== false; // default inline
if (!inline) {
const { runFlow } = await import('../flow-runner');
await runFlow(flow, { args: s.args || {}, returnLogs: false });
return {} as ExecResult;
}
const { defaultEdgesOnly, topoOrder, mapDagNodeToStep, waitForNetworkIdle, waitForNavigation } =
await import('../rr-utils');
const vars = ctx.vars;
if (s.args && typeof s.args === 'object') Object.assign(vars, s.args);
// DAG is required - flow-store guarantees nodes/edges via normalization
const nodes = ((flow as any).nodes || []) as any[];
const edges = ((flow as any).edges || []) as any[];
if (nodes.length === 0) {
throw new Error(
'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.',
);
}
const defaultEdges = defaultEdgesOnly(edges as any);
const order = topoOrder(nodes as any, defaultEdges as any);
const stepsToRun: any[] = order.map((n) => mapDagNodeToStep(n as any));
for (const st of stepsToRun) {
const t0 = Date.now();
const maxRetries = Math.max(0, (st as any).retry?.count ?? 0);
const baseInterval = Math.max(0, (st as any).retry?.intervalMs ?? 0);
let attempt = 0;
const doDelay = async (i: number) => {
const delay =
baseInterval > 0
? (st as any).retry?.backoff === 'exp'
? baseInterval * Math.pow(2, i)
: baseInterval
: 0;
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
};
while (true) {
try {
const beforeInfo = await (async () => {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
return { url: tab?.url || '', status: (tab as any)?.status || '' };
})();
const { executeStep } = await import('../nodes');
const result = await executeStep(ctx as any, st as any);
if ((st.type === 'click' || st.type === 'dblclick') && (st as any).after) {
const after = (st as any).after as any;
if (after.waitForNavigation)
await waitForNavigation((st as any).timeoutMs, beforeInfo.url);
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((st as any).timeoutMs || 5000, 120000), 1200);
}
if (!result?.alreadyLogged)
ctx.logger({ stepId: st.id, status: 'success', tookMs: Date.now() - t0 } as any);
break;
} catch (e: any) {
if (attempt < maxRetries) {
ctx.logger({
stepId: st.id,
status: 'retrying',
message: e?.message || String(e),
} as any);
await doDelay(attempt);
attempt += 1;
continue;
}
ctx.logger({
stepId: st.id,
status: 'failed',
message: e?.message || String(e),
tookMs: Date.now() - t0,
} as any);
throw e;
}
}
}
return {} as ExecResult;
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Disposables Utility
*
* Provides deterministic cleanup for event listeners, observers, and other resources.
* Ensures proper cleanup order (LIFO) and prevents memory leaks.
*/
/** Function that performs cleanup */
export type DisposeFn = () => void;
/**
* Manages a collection of disposable resources.
* Resources are disposed in reverse order (LIFO).
*/
export class Disposer {
private disposed = false;
private readonly disposers: DisposeFn[] = [];
/** Whether this disposer has already been disposed */
get isDisposed(): boolean {
return this.disposed;
}
/**
* Add a dispose function to be called during cleanup.
* If already disposed, the function is called immediately.
*/
add(dispose: DisposeFn): void {
if (this.disposed) {
try {
dispose();
} catch {
// Best-effort cleanup for late additions
}
return;
}
this.disposers.push(dispose);
}
/**
* Add an event listener and automatically remove it on dispose.
*/
listen<K extends keyof WindowEventMap>(
target: Window,
type: K,
listener: (ev: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
listen<K extends keyof DocumentEventMap>(
target: Document,
type: K,
listener: (ev: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
listen<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
listener: (ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
target.addEventListener(type, listener, options);
this.add(() => target.removeEventListener(type, listener, options));
}
/**
* Add a ResizeObserver and automatically disconnect it on dispose.
*/
observeResize(
target: Element,
callback: ResizeObserverCallback,
options?: ResizeObserverOptions,
): ResizeObserver {
const observer = new ResizeObserver(callback);
observer.observe(target, options);
this.add(() => observer.disconnect());
return observer;
}
/**
* Add a MutationObserver and automatically disconnect it on dispose.
*/
observeMutation(
target: Node,
callback: MutationCallback,
options?: MutationObserverInit,
): MutationObserver {
const observer = new MutationObserver(callback);
observer.observe(target, options);
this.add(() => observer.disconnect());
return observer;
}
/**
* Add a requestAnimationFrame and automatically cancel it on dispose.
* Returns a function to manually cancel the frame.
*/
requestAnimationFrame(callback: FrameRequestCallback): () => void {
const id = requestAnimationFrame(callback);
let cancelled = false;
const cancel = () => {
if (cancelled) return;
cancelled = true;
cancelAnimationFrame(id);
};
this.add(cancel);
return cancel;
}
/**
* Dispose all registered resources in reverse order.
* Safe to call multiple times.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Dispose in reverse order (LIFO)
for (let i = this.disposers.length - 1; i >= 0; i--) {
try {
this.disposers[i]();
} catch {
// Best-effort cleanup, continue with remaining disposers
}
}
this.disposers.length = 0;
}
}
```
--------------------------------------------------------------------------------
/packages/shared/src/types.ts:
--------------------------------------------------------------------------------
```typescript
export enum NativeMessageType {
START = 'start',
STARTED = 'started',
STOP = 'stop',
STOPPED = 'stopped',
PING = 'ping',
PONG = 'pong',
ERROR = 'error',
PROCESS_DATA = 'process_data',
PROCESS_DATA_RESPONSE = 'process_data_response',
CALL_TOOL = 'call_tool',
CALL_TOOL_RESPONSE = 'call_tool_response',
// Additional message types used in Chrome extension
SERVER_STARTED = 'server_started',
SERVER_STOPPED = 'server_stopped',
ERROR_FROM_NATIVE_HOST = 'error_from_native_host',
CONNECT_NATIVE = 'connectNative',
ENSURE_NATIVE = 'ensure_native',
PING_NATIVE = 'ping_native',
DISCONNECT_NATIVE = 'disconnect_native',
}
export interface NativeMessage<P = any, E = any> {
type?: NativeMessageType;
responseToRequestId?: string;
payload?: P;
error?: E;
}
// ============================================================
// Element Picker Types (chrome_request_element_selection)
// ============================================================
/**
* A single element selection request from the AI.
*/
export interface ElementPickerRequest {
/**
* Optional stable request id. If omitted, the extension will generate one.
*/
id?: string;
/**
* Short label shown to the user (e.g., "Login button").
*/
name: string;
/**
* Optional longer instruction shown to the user.
*/
description?: string;
}
/**
* Bounding rectangle of a picked element.
*/
export interface PickedElementRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* Center point of a picked element.
*/
export interface PickedElementPoint {
x: number;
y: number;
}
/**
* A picked element that can be used with other tools (click, fill, etc.).
*/
export interface PickedElement {
/**
* Element ref written into window.__claudeElementMap (frame-local).
* Can be used directly with chrome_click_element, chrome_fill_or_select, etc.
*/
ref: string;
/**
* Best-effort stable CSS selector.
*/
selector: string;
/**
* Selector type (currently CSS only).
*/
selectorType: 'css';
/**
* Bounding rect in the element's frame viewport coordinates.
*/
rect: PickedElementRect;
/**
* Center point in the element's frame viewport coordinates.
* Can be used as coordinates for chrome_computer.
*/
center: PickedElementPoint;
/**
* Optional text snippet to help verify the selection.
*/
text?: string;
/**
* Lowercased tag name.
*/
tagName?: string;
/**
* Chrome frameId for iframe targeting.
* Pass this to chrome_click_element/chrome_fill_or_select for cross-frame support.
*/
frameId: number;
}
/**
* Result for a single element selection request.
*/
export interface ElementPickerResultItem {
/**
* The request id (matches the input request).
*/
id: string;
/**
* The request name (for reference).
*/
name: string;
/**
* The picked element, or null if not selected.
*/
element: PickedElement | null;
/**
* Error message if selection failed for this request.
*/
error?: string;
}
/**
* Result of the chrome_request_element_selection tool.
*/
export interface ElementPickerResult {
/**
* True if the user confirmed all selections.
*/
success: boolean;
/**
* Session identifier for this picker session.
*/
sessionId: string;
/**
* Timeout value used for this session.
*/
timeoutMs: number;
/**
* True if the user cancelled the selection.
*/
cancelled?: boolean;
/**
* True if the selection timed out.
*/
timedOut?: boolean;
/**
* List of request IDs that were not selected (for debugging).
*/
missingRequestIds?: string[];
/**
* Results for each requested element.
*/
results: ElementPickerResultItem[];
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/styles/tailwind.css:
--------------------------------------------------------------------------------
```css
@import 'tailwindcss';
/* App background and card helpers */
@layer base {
html,
body,
#app {
height: 100%;
}
body {
@apply bg-slate-50 text-slate-800;
}
/* Record&Replay builder design tokens */
.rr-theme {
--rr-bg: #f8fafc;
--rr-topbar: rgba(255, 255, 255, 0.9);
--rr-card: #ffffff;
--rr-elevated: #ffffff;
--rr-border: #e5e7eb;
--rr-subtle: #f3f4f6;
--rr-text: #0f172a;
--rr-text-weak: #475569;
--rr-muted: #64748b;
--rr-brand: #7c3aed;
--rr-brand-strong: #5b21b6;
--rr-accent: #0ea5e9;
--rr-success: #10b981;
--rr-warn: #f59e0b;
--rr-danger: #ef4444;
--rr-dot: rgba(2, 6, 23, 0.08);
}
.rr-theme[data-theme='dark'] {
--rr-bg: #0b1020;
--rr-topbar: rgba(12, 15, 24, 0.8);
--rr-card: #0f1528;
--rr-elevated: #121a33;
--rr-border: rgba(255, 255, 255, 0.08);
--rr-subtle: rgba(255, 255, 255, 0.04);
--rr-text: #e5e7eb;
--rr-text-weak: #cbd5e1;
--rr-muted: #94a3b8;
--rr-brand: #a78bfa;
--rr-brand-strong: #7c3aed;
--rr-accent: #38bdf8;
--rr-success: #34d399;
--rr-warn: #fbbf24;
--rr-danger: #f87171;
--rr-dot: rgba(226, 232, 240, 0.08);
}
}
@layer components {
.card {
@apply rounded-xl shadow-md border;
background: var(--rr-card);
border-color: var(--rr-border);
}
/* Generic buttons used across builder */
.btn {
@apply inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition;
background: var(--rr-card);
color: var(--rr-text);
border: 1px solid var(--rr-border);
}
.btn:hover {
@apply shadow-sm;
background: var(--rr-subtle);
}
.btn[disabled] {
@apply opacity-60 cursor-not-allowed;
}
.btn.primary {
color: #fff;
background: var(--rr-brand-strong);
border-color: var(--rr-brand-strong);
}
.btn.primary:hover {
filter: brightness(1.05);
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.mini {
@apply inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium;
background: var(--rr-card);
color: var(--rr-text);
border: 1px solid var(--rr-border);
}
.mini:hover {
background: var(--rr-subtle);
}
.mini.danger {
background: color-mix(in oklab, var(--rr-danger) 8%, transparent);
border-color: color-mix(in oklab, var(--rr-danger) 24%, var(--rr-border));
color: var(--rr-text);
}
.input {
@apply w-full px-3 py-2 rounded-lg text-sm;
background: var(--rr-card);
color: var(--rr-text);
border: 1px solid var(--rr-border);
outline: none;
}
.input:focus {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--rr-brand) 26%, transparent);
border-color: var(--rr-brand);
}
.select {
@apply w-full px-3 py-2 rounded-lg text-sm;
background: var(--rr-card);
color: var(--rr-text);
border: 1px solid var(--rr-border);
outline: none;
}
.textarea {
@apply w-full rounded-lg text-sm;
padding: 10px 12px;
background: var(--rr-card);
color: var(--rr-text);
border: 1px solid var(--rr-border);
outline: none;
}
.label {
@apply text-sm;
color: var(--rr-muted);
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-purple {
background: color-mix(in oklab, var(--rr-brand) 14%, transparent);
color: var(--rr-brand);
}
/* Builder topbar */
.rr-topbar {
height: 56px;
border-bottom: 1px solid var(--rr-border);
background: var(--rr-topbar);
}
/* Dot grid background utility for canvas container */
.rr-dot-grid {
background-image: radial-gradient(var(--rr-dot) 1px, transparent 1px);
background-size: 20px 20px;
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="form-section">
<div class="section-header">
<span class="section-title">{{ title || '选择器' }}</span>
<button v-if="allowPick" class="btn-sm btn-primary" @click="pickFromPage">从页面选择</button>
</div>
<div class="selector-list" data-field="target.candidates">
<div class="selector-item" v-for="(c, i) in list" :key="i">
<select class="form-select-sm" v-model="c.type">
<option value="css">CSS</option>
<option value="attr">Attr</option>
<option value="aria">ARIA</option>
<option value="text">Text</option>
<option value="xpath">XPath</option>
</select>
<input class="form-input-sm flex-1" v-model="c.value" placeholder="选择器值" />
<button class="btn-icon-sm" @click="move(i, -1)" :disabled="i === 0">↑</button>
<button class="btn-icon-sm" @click="move(i, 1)" :disabled="i === list.length - 1">↓</button>
<button class="btn-icon-sm danger" @click="remove(i)">×</button>
</div>
<button class="btn-sm" @click="add">+ 添加选择器</button>
</div>
</div>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
const props = defineProps<{
node: NodeBase;
allowPick?: boolean;
targetKey?: string;
title?: string;
}>();
const key = (props.targetKey || 'target') as string;
function ensureTarget() {
const n: any = props.node;
if (!n.config) n.config = {};
if (!n.config[key]) n.config[key] = { candidates: [] };
if (!Array.isArray(n.config[key].candidates)) n.config[key].candidates = [];
}
const list = {
get value() {
ensureTarget();
return ((props.node as any).config[key].candidates || []) as Array<{
type: string;
value: string;
}>;
},
} as any as Array<{ type: string; value: string }>;
function add() {
ensureTarget();
(props.node as any).config[key].candidates.push({ type: 'css', value: '' });
}
function remove(i: number) {
ensureTarget();
(props.node as any).config[key].candidates.splice(i, 1);
}
function move(i: number, d: number) {
ensureTarget();
const arr = (props.node as any).config[key].candidates as any[];
const j = i + d;
if (j < 0 || j >= arr.length) return;
const t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
async function ensurePickerInjected(tabId: number) {
try {
const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);
if (pong && pong.status === 'pong') return;
} catch {}
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ['inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
} catch (e) {
console.warn('inject picker helper failed:', e);
}
}
async function pickFromPage() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs?.[0]?.id;
if (typeof tabId !== 'number') return;
await ensurePickerInjected(tabId);
const resp: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);
if (!resp || !resp.success) return;
ensureTarget();
const n: any = props.node;
const arr = Array.isArray(resp.candidates) ? resp.candidates : [];
const seen = new Set<string>();
const merged: any[] = [];
for (const c of arr) {
if (!c || !c.type || !c.value) continue;
const key = `${c.type}|${c.value}`;
if (!seen.has(key)) {
seen.add(key);
merged.push({ type: String(c.type), value: String(c.value) });
}
}
n.config[key].candidates = merged;
} catch (e) {
console.warn('pickFromPage failed:', e);
}
}
</script>
<style scoped>
/* No local styles; inherit from parent panel via :deep selectors */
</style>
```
--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/strategies/testid.ts:
--------------------------------------------------------------------------------
```typescript
/**
* TestID Strategy - Attribute-based selector strategy
*
* Generates selectors based on stable attributes like data-testid, data-cy,
* as well as semantic attributes like name, title, and alt.
*/
import type { SelectorCandidate, SelectorStrategy } from '../types';
// =============================================================================
// Constants
// =============================================================================
/** Tags that commonly use form-related attributes */
const FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'button']);
/** Tags that commonly use the 'alt' attribute */
const ALT_ATTRIBUTE_TAGS = new Set(['img', 'area']);
/** Tags that commonly use the 'title' attribute (most elements can have it) */
const TITLE_ATTRIBUTE_TAGS = new Set(['img', 'a', 'abbr', 'iframe', 'link']);
/**
* Mapping of attributes to their preferred tag prefixes.
* When an attribute-only selector is not unique, we try tag-prefixed form
* only for elements where that attribute is semantically meaningful.
*/
const ATTR_TAG_PREFERENCES: Record<string, Set<string>> = {
name: FORM_ELEMENT_TAGS,
alt: ALT_ATTRIBUTE_TAGS,
title: TITLE_ATTRIBUTE_TAGS,
};
// =============================================================================
// Helpers
// =============================================================================
function makeAttrSelector(attr: string, value: string, cssEscape: (v: string) => string): string {
return `[${attr}="${cssEscape(value)}"]`;
}
/**
* Determine if tag prefix should be tried for disambiguation.
*
* Rules:
* - data-* attributes: try for form elements only
* - name: try for form elements (input, textarea, select, button)
* - alt: try for img, area, input[type=image]
* - title: try for common elements that use title semantically
* - Default: try for any tag
*/
function shouldTryTagPrefix(attr: string, tag: string, element: Element): boolean {
if (!tag) return false;
// For data-* test attributes, use form element heuristic
if (attr.startsWith('data-')) {
return FORM_ELEMENT_TAGS.has(tag);
}
// For semantic attributes, check the preference mapping
const preferredTags = ATTR_TAG_PREFERENCES[attr];
if (preferredTags) {
if (preferredTags.has(tag)) return true;
// Special case: input[type=image] also uses alt
if (attr === 'alt' && tag === 'input') {
const type = element.getAttribute('type');
return type === 'image';
}
return false;
}
// Default: try tag prefix for any element
return true;
}
// =============================================================================
// Strategy Export
// =============================================================================
export const testIdStrategy: SelectorStrategy = {
id: 'testid',
generate(ctx) {
const { element, options, helpers } = ctx;
const out: SelectorCandidate[] = [];
const tag = element.tagName?.toLowerCase?.() ?? '';
for (const attr of options.testIdAttributes) {
const raw = element.getAttribute(attr);
const value = raw?.trim();
if (!value) continue;
const attrOnly = makeAttrSelector(attr, value, helpers.cssEscape);
// Try attribute-only selector first
if (helpers.isUnique(attrOnly)) {
out.push({
type: 'attr',
value: attrOnly,
source: 'generated',
strategy: 'testid',
});
continue;
}
// Try tag-prefixed form if appropriate for this attribute/element combo
if (shouldTryTagPrefix(attr, tag, element)) {
const withTag = `${tag}${attrOnly}`;
if (helpers.isUnique(withTag)) {
out.push({
type: 'attr',
value: withTag,
source: 'generated',
strategy: 'testid',
});
}
}
}
return out;
},
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolCallStep.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="space-y-1">
<div class="flex items-baseline gap-2 flex-wrap">
<!-- Label -->
<span
class="text-[11px] font-bold uppercase tracking-wider flex-shrink-0"
:style="{
color: labelColor,
}"
>
{{ item.tool.label }}
</span>
<!-- Content based on tool kind -->
<code
v-if="item.tool.kind === 'grep' || item.tool.kind === 'read'"
class="text-xs px-1.5 py-0.5 cursor-pointer ac-chip-hover"
:style="{
fontFamily: 'var(--ac-font-mono)',
backgroundColor: 'var(--ac-chip-bg)',
color: 'var(--ac-chip-text)',
borderRadius: 'var(--ac-radius-button)',
}"
:title="item.tool.filePath || item.tool.pattern"
>
{{ item.tool.title }}
</code>
<span
v-else
class="text-xs"
:style="{
fontFamily: 'var(--ac-font-mono)',
color: 'var(--ac-text-muted)',
}"
:title="item.tool.filePath || item.tool.command"
>
{{ item.tool.title }}
</span>
<!-- Diff Stats Preview (for edit) -->
<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>
<!-- Streaming indicator -->
<span
v-if="item.isStreaming"
class="text-xs italic"
:style="{ color: 'var(--ac-text-subtle)' }"
>
...
</span>
</div>
<!-- Subtitle (command description or search path) -->
<div
v-if="subtitle"
class="text-[10px] pl-10 truncate"
:style="{ color: 'var(--ac-text-subtle)' }"
:title="subtitleFull"
>
{{ subtitle }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { TimelineItem } from '../../../composables/useAgentThreads';
const props = defineProps<{
item: Extract<TimelineItem, { kind: 'tool_use' }>;
}>();
const labelColor = computed(() => {
if (props.item.tool.kind === 'edit') {
return 'var(--ac-accent)';
}
return 'var(--ac-text-subtle)';
});
const hasDiffStats = computed(() => {
const stats = props.item.tool.diffStats;
if (!stats) return false;
return stats.addedLines !== undefined || stats.deletedLines !== undefined;
});
const subtitle = computed(() => {
const tool = props.item.tool;
// For commands: show the actual command if title is description
if (tool.kind === 'run' && tool.commandDescription && tool.command) {
return tool.command.length > 60 ? tool.command.slice(0, 57) + '...' : tool.command;
}
// For file operations: show full path if title is just filename
if ((tool.kind === 'edit' || tool.kind === 'read') && tool.filePath) {
if (tool.filePath !== tool.title && !tool.title.includes('/')) {
return tool.filePath;
}
}
// For search: show search path if provided
if (tool.kind === 'grep' && tool.searchPath) {
return `in ${tool.searchPath}`;
}
return undefined;
});
const subtitleFull = computed(() => {
const tool = props.item.tool;
if (tool.kind === 'run' && tool.command) return tool.command;
if (tool.filePath) return tool.filePath;
if (tool.searchPath) return tool.searchPath;
return undefined;
});
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/storage-manager.ts:
--------------------------------------------------------------------------------
```typescript
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
/**
* Get storage statistics
*/
export async function handleGetStorageStats(): Promise<{
success: boolean;
stats?: any;
error?: string;
}> {
try {
// Get ContentIndexer statistics
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
// Note: Semantic engine initialization is now user-controlled
// ContentIndexer will be initialized when user manually triggers semantic engine initialization
// Get statistics
const stats = contentIndexer.getStats();
return {
success: true,
stats: {
indexedPages: stats.indexedPages || 0,
totalDocuments: stats.totalDocuments || 0,
totalTabs: stats.totalTabs || 0,
indexSize: stats.indexSize || 0,
isInitialized: stats.isInitialized || false,
semanticEngineReady: stats.semanticEngineReady || false,
semanticEngineInitializing: stats.semanticEngineInitializing || false,
},
};
} catch (error: any) {
console.error('Background: Failed to get storage stats:', error);
return {
success: false,
error: error.message,
stats: {
indexedPages: 0,
totalDocuments: 0,
totalTabs: 0,
indexSize: 0,
isInitialized: false,
semanticEngineReady: false,
semanticEngineInitializing: false,
},
};
}
}
/**
* Clear all data
*/
export async function handleClearAllData(): Promise<{ success: boolean; error?: string }> {
try {
// 1. Clear all ContentIndexer indexes
try {
const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
const contentIndexer = getGlobalContentIndexer();
await contentIndexer.clearAllIndexes();
console.log('Storage: ContentIndexer indexes cleared successfully');
} catch (indexerError) {
console.warn('Background: Failed to clear ContentIndexer indexes:', indexerError);
// Continue with other cleanup operations
}
// 2. Clear all VectorDatabase data
try {
const { clearAllVectorData } = await import('@/utils/vector-database');
await clearAllVectorData();
console.log('Storage: Vector database data cleared successfully');
} catch (vectorError) {
console.warn('Background: Failed to clear vector data:', vectorError);
// Continue with other cleanup operations
}
// 3. Clear related data in chrome.storage (preserve model preferences)
try {
const keysToRemove = ['vectorDatabaseStats', 'lastCleanupTime', 'contentIndexerStats'];
await chrome.storage.local.remove(keysToRemove);
console.log('Storage: Chrome storage data cleared successfully');
} catch (storageError) {
console.warn('Background: Failed to clear chrome storage data:', storageError);
}
return { success: true };
} catch (error: any) {
console.error('Background: Failed to clear all data:', error);
return { success: false, error: error.message };
}
}
/**
* Initialize storage manager module message listeners
*/
export const initStorageManagerListener = () => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === BACKGROUND_MESSAGE_TYPES.GET_STORAGE_STATS) {
handleGetStorageStats()
.then((result: { success: boolean; stats?: any; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
} else if (message.type === BACKGROUND_MESSAGE_TYPES.CLEAR_ALL_DATA) {
handleClearAllData()
.then((result: { success: boolean; error?: string }) => sendResponse(result))
.catch((error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
});
};
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSettingsMenu.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div
v-if="open"
class="fixed top-12 right-4 z-50 min-w-[180px] py-2"
:style="{
backgroundColor: 'var(--ac-surface, #ffffff)',
border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
borderRadius: 'var(--ac-radius-inner, 8px)',
boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',
}"
>
<!-- Theme Section -->
<div
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
>
Theme
</div>
<button
v-for="t in themes"
:key="t.id"
class="w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item"
:style="{
color: theme === t.id ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',
}"
@click="$emit('theme:set', t.id)"
>
<span>{{ t.label }}</span>
<svg
v-if="theme === t.id"
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="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Divider -->
<div
class="my-2"
:style="{
borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
}"
/>
<!-- Input Section -->
<div
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
>
Input
</div>
<button
class="w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item"
:style="{ color: 'var(--ac-text, #1a1a1a)' }"
@click="$emit('fakeCaret:toggle', !fakeCaretEnabled)"
>
<span>Comet caret</span>
<svg
v-if="fakeCaretEnabled"
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="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Divider -->
<div
class="my-2"
:style="{
borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
}"
/>
<!-- Storage Section -->
<div
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
>
Storage
</div>
<button
class="w-full px-3 py-2 text-left text-sm ac-menu-item"
:style="{ color: 'var(--ac-text, #1a1a1a)' }"
@click="$emit('attachments:open')"
>
Clear Attachment Cache
</button>
<!-- Divider -->
<div
class="my-2"
:style="{
borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
}"
/>
<!-- Reconnect -->
<button
class="w-full px-3 py-2 text-left text-sm ac-menu-item"
:style="{ color: 'var(--ac-text, #1a1a1a)' }"
@click="$emit('reconnect')"
>
Reconnect Server
</button>
</div>
</template>
<script lang="ts" setup>
import { type AgentThemeId, THEME_LABELS } from '../../composables';
defineProps<{
open: boolean;
theme: AgentThemeId;
/** Fake caret (comet effect) enabled state */
fakeCaretEnabled?: boolean;
}>();
defineEmits<{
'theme:set': [theme: AgentThemeId];
reconnect: [];
'attachments:open': [];
'fakeCaret:toggle': [enabled: boolean];
}>();
const themes: { id: AgentThemeId; label: string }[] = [
{ id: 'warm-editorial', label: THEME_LABELS['warm-editorial'] },
{ id: 'blueprint-architect', label: THEME_LABELS['blueprint-architect'] },
{ id: 'zen-journal', label: THEME_LABELS['zen-journal'] },
{ id: 'neo-pop', label: THEME_LABELS['neo-pop'] },
{ id: 'dark-console', label: THEME_LABELS['dark-console'] },
{ id: 'swiss-grid', label: THEME_LABELS['swiss-grid'] },
];
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="form-section">
<div class="section-header">
<span class="section-title">If / else</span>
<button class="btn-sm" @click="addIfCase">+ Add</button>
</div>
<div class="text-xs text-slate-500" style="padding: 0 20px"
>使用表达式定义分支,支持变量与常见比较运算符。</div
>
<div class="if-case-list" data-field="if.branches">
<div class="if-case-item" v-for="(c, i) in ifBranches" :key="c.id">
<div class="if-case-header">
<input class="form-input-sm flex-1" v-model="c.name" placeholder="分支名称(可选)" />
<button class="btn-icon-sm danger" @click="removeIfCase(i)" title="删除">×</button>
</div>
<div class="if-case-expr">
<VarInput
v-model="c.expr"
:variables="variablesNormalized"
format="workflowDot"
:placeholder="'workflow.' + (variablesNormalized[0]?.key || 'var') + ' == 5'"
/>
<div class="if-toolbar">
<select
class="form-select-sm"
@change="(e: any) => insertVar(e.target.value, i)"
:value="''"
>
<option value="" disabled>插入变量</option>
<option v-for="v in variables" :key="v.key" :value="v.key">{{ v.key }}</option>
</select>
<select
class="form-select-sm"
@change="(e: any) => insertOp(e.target.value, i)"
:value="''"
>
<option value="" disabled>运算符</option>
<option v-for="op in ops" :key="op" :value="op">{{ op }}</option>
</select>
</div>
</div>
</div>
<div class="if-case-else" v-if="elseEnabled">
<div class="text-xs text-slate-500">Else 分支(无需表达式,将匹配以上条件都不成立时)</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import { computed } from 'vue';
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
import { newId } from '@/entrypoints/popup/components/builder/model/transforms';
import VarInput from '@/entrypoints/popup/components/builder/widgets/VarInput.vue';
import type { VariableOption } from '@/entrypoints/popup/components/builder/model/variables';
const props = defineProps<{ node: NodeBase; variables?: Array<{ key: string }> }>();
const variablesNormalized = computed<VariableOption[]>(() =>
(props.variables || []).map((v) => ({ key: v.key, origin: 'global' }) as VariableOption),
);
const ops = ['==', '!=', '>', '>=', '<', '<=', '&&', '||'];
const ifBranches = computed<Array<{ id: string; name?: string; expr: string }>>({
get() {
try {
return Array.isArray((props.node as any)?.config?.branches)
? ((props.node as any).config.branches as any[])
: [];
} catch {
return [] as any;
}
},
set(arr) {
try {
(props.node as any).config.branches = arr;
} catch {}
},
});
const elseEnabled = computed<boolean>({
get() {
try {
return (props.node as any)?.config?.else !== false;
} catch {
return true;
}
},
set(v) {
try {
(props.node as any).config.else = !!v;
} catch {}
},
});
function addIfCase() {
const arr = ifBranches.value.slice();
arr.push({ id: newId('case'), name: '', expr: '' });
ifBranches.value = arr;
}
function removeIfCase(i: number) {
const arr = ifBranches.value.slice();
arr.splice(i, 1);
ifBranches.value = arr;
}
function insertVar(key: string, idx: number) {
if (!key) return;
const arr = ifBranches.value.slice();
const token = `workflow.${key}`;
arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + token;
ifBranches.value = arr;
}
function insertOp(op: string, idx: number) {
if (!op) return;
const arr = ifBranches.value.slice();
arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + op;
ifBranches.value = arr;
}
</script>
<style scoped></style>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts:
--------------------------------------------------------------------------------
```typescript
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { handleCallTool } from '@/entrypoints/background/tools';
import type { StepAssert } from '../types';
import { expandTemplatesDeep } from '../rr-utils';
import type { ExecCtx, ExecResult, NodeRuntime } from './types';
export const assertNode: NodeRuntime<StepAssert> = {
validate: (step) => {
const s = step as any;
const ok = !!s.assert;
if (ok && s.assert && 'attribute' in s.assert) {
const a = s.assert.attribute || {};
if (!a.selector || !a.name)
return { ok: false, errors: ['assert.attribute: 需提供 selector 与 name'] };
}
return ok ? { ok } : { ok, errors: ['缺少断言条件'] };
},
run: async (ctx: ExecCtx, step: StepAssert) => {
const s = expandTemplatesDeep(step as StepAssert, ctx.vars) as any;
const failStrategy = (s as any).failStrategy || 'stop';
const fail = (msg: string) => {
if (failStrategy === 'warn') {
ctx.logger({ stepId: (step as any).id, status: 'warning', message: msg });
return { alreadyLogged: true } as any;
}
throw new Error(msg);
};
if ('textPresent' in s.assert) {
const text = (s.assert as any).textPresent;
const res = await handleCallTool({
name: TOOL_NAMES.BROWSER.COMPUTER,
args: { action: 'wait', text, appear: true, timeout: (step as any).timeoutMs || 5000 },
});
if ((res as any).isError) return fail('assert text failed');
} else if ('exists' in s.assert || 'visible' in s.assert) {
const selector = (s.assert as any).exists || (s.assert as any).visible;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) return fail('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const ensured: any = (await chrome.tabs.sendMessage(
tabId,
{
action: 'ensureRefForSelector',
selector,
} as any,
{ frameId: ctx.frameId } as any,
)) as any;
if (!ensured || !ensured.success) return fail('assert selector not found');
if ('visible' in s.assert) {
const rect = ensured && ensured.center ? ensured.center : null;
if (!rect) return fail('assert visible failed');
}
} else if ('attribute' in s.assert) {
const { selector, name, equals, matches } = (s.assert as any).attribute || {};
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const firstTab = tabs && tabs[0];
const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
if (!tabId) return fail('Active tab not found');
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const resp: any = (await chrome.tabs.sendMessage(
tabId,
{ action: 'getAttributeForSelector', selector, name } as any,
{ frameId: ctx.frameId } as any,
)) as any;
if (!resp || !resp.success) return fail('assert attribute: element not found');
const actual: string | null = resp.value ?? null;
if (equals !== undefined && equals !== null) {
const expected = String(equals);
if (String(actual) !== String(expected))
return fail(
`assert attribute equals failed: ${name} actual=${String(actual)} expected=${String(expected)}`,
);
} else if (matches !== undefined && matches !== null) {
try {
const re = new RegExp(String(matches));
if (!re.test(String(actual)))
return fail(
`assert attribute matches failed: ${name} actual=${String(actual)} regex=${String(matches)}`,
);
} catch {
return fail(`invalid regex for attribute matches: ${String(matches)}`);
}
} else {
if (actual == null) return fail(`assert attribute failed: ${name} missing`);
}
}
return {} as ExecResult;
},
};
```
--------------------------------------------------------------------------------
/app/native-server/src/agent/engines/types.ts:
--------------------------------------------------------------------------------
```typescript
import type { AgentAttachment, RealtimeEvent } from '../types';
import type { CodexEngineConfig } from 'chrome-mcp-shared';
export interface EngineInitOptions {
sessionId: string;
instruction: string;
model?: string;
projectRoot?: string;
requestId: string;
/**
* AbortSignal for cancellation support.
*/
signal?: AbortSignal;
/**
* Optional attachments (images/files) to include with the instruction.
* Note: When using persisted attachments, use resolvedImagePaths instead.
*/
attachments?: AgentAttachment[];
/**
* Resolved absolute paths to persisted image files.
* These are used by engines instead of writing temp files from base64.
* Set by chat-service after saving attachments to persistent storage.
*/
resolvedImagePaths?: string[];
/**
* Optional project ID for session persistence.
* When provided, engines can use this to save/load session state.
*/
projectId?: string;
/**
* Optional database session ID (sessions.id) for session-scoped configuration and persistence.
*/
dbSessionId?: string;
/**
* Optional session-scoped permission mode override (Claude SDK option).
*/
permissionMode?: string;
/**
* Optional session-scoped permission bypass override (Claude SDK option).
*/
allowDangerouslySkipPermissions?: boolean;
/**
* Optional session-scoped system prompt configuration.
*/
systemPromptConfig?: unknown;
/**
* Optional session-scoped engine option overrides.
*/
optionsConfig?: unknown;
/**
* Optional Claude session ID (UUID) for resuming a previous session.
* Only applicable to ClaudeEngine; retrieved from sessions.engineSessionId (preferred)
* or project's activeClaudeSessionId (legacy fallback).
*/
resumeClaudeSessionId?: string;
/**
* Whether to use Claude Code Router (CCR) for this request.
* Only applicable to ClaudeEngine; when true, CCR will be auto-detected.
*/
useCcr?: boolean;
/**
* Optional Codex-specific configuration overrides.
* Only applicable to CodexEngine; merged with DEFAULT_CODEX_CONFIG.
*/
codexConfig?: Partial<CodexEngineConfig>;
}
/**
* Callback to persist Claude session ID after initialization.
*/
export type ClaudeSessionPersistCallback = (sessionId: string) => Promise<void>;
/**
* Management information extracted from Claude SDK system:init message.
*/
export interface ClaudeManagementInfo {
tools?: string[];
agents?: string[];
/** Plugins with name and path (SDK returns { name, path }[]) */
plugins?: Array<{ name: string; path?: string }>;
skills?: string[];
mcpServers?: Array<{ name: string; status: string }>;
slashCommands?: string[];
model?: string;
permissionMode?: string;
cwd?: string;
outputStyle?: string;
betas?: string[];
claudeCodeVersion?: string;
apiKeySource?: string;
}
/**
* Callback to persist management information after SDK initialization.
*/
export type ManagementInfoPersistCallback = (info: ClaudeManagementInfo) => Promise<void>;
export type EngineName = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
export interface EngineExecutionContext {
/**
* Emit a realtime event to all connected clients for the current session.
*/
emit(event: RealtimeEvent): void;
/**
* Optional callback to persist Claude session ID after SDK initialization.
* Only called by ClaudeEngine when projectId is provided.
*/
persistClaudeSessionId?: ClaudeSessionPersistCallback;
/**
* Optional callback to persist management information after SDK initialization.
* Only called by ClaudeEngine when dbSessionId is provided.
*/
persistManagementInfo?: ManagementInfoPersistCallback;
}
export interface AgentEngine {
name: EngineName;
/**
* Whether this engine can act as an MCP client natively.
*/
supportsMcp?: boolean;
initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void>;
}
/**
* Represents a running engine execution that can be cancelled.
*/
export interface RunningExecution {
requestId: string;
sessionId: string;
engineName: EngineName;
abortController: AbortController;
startedAt: Date;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/utils/indexeddb-client.ts:
--------------------------------------------------------------------------------
```typescript
// indexeddb-client.ts
// Generic IndexedDB client with robust transaction handling and small helpers.
export type UpgradeHandler = (
db: IDBDatabase,
oldVersion: number,
tx: IDBTransaction | null,
) => void;
export class IndexedDbClient {
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(
private name: string,
private version: number,
private onUpgrade: UpgradeHandler,
) {}
async openDb(): Promise<IDBDatabase> {
if (this.dbPromise) return this.dbPromise;
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(this.name, this.version);
req.onupgradeneeded = (event) => {
const db = req.result;
const oldVersion = (event as IDBVersionChangeEvent).oldVersion || 0;
const tx = req.transaction as IDBTransaction | null;
try {
this.onUpgrade(db, oldVersion, tx);
} catch (e) {
console.error('IndexedDbClient upgrade failed:', e);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () =>
reject(new Error(`IndexedDB open failed: ${req.error?.message || req.error}`));
});
return this.dbPromise;
}
async tx<T>(
storeName: string,
mode: IDBTransactionMode,
op: (store: IDBObjectStore, txn: IDBTransaction) => T | Promise<T>,
): Promise<T> {
const db = await this.openDb();
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const st = transaction.objectStore(storeName);
let opResult: T | undefined;
let opError: any;
transaction.oncomplete = () => resolve(opResult as T);
transaction.onerror = () =>
reject(
new Error(
`IDB transaction error on ${storeName}: ${transaction.error?.message || transaction.error}`,
),
);
transaction.onabort = () =>
reject(
new Error(
`IDB transaction aborted on ${storeName}: ${transaction.error?.message || opError || 'unknown'}`,
),
);
Promise.resolve()
.then(() => op(st, transaction))
.then((res) => {
opResult = res as T;
})
.catch((err) => {
opError = err;
try {
transaction.abort();
} catch {}
});
});
}
async getAll<T>(store: string): Promise<T[]> {
return this.tx<T[]>(store, 'readonly', (st) =>
this.promisifyRequest<any[]>(st.getAll(), store, 'getAll').then((res) => (res as T[]) || []),
);
}
async get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {
return this.tx<T | undefined>(store, 'readonly', (st) =>
this.promisifyRequest<T | undefined>(st.get(key), store, `get(${String(key)})`).then(
(res) => res as any,
),
);
}
async put<T>(store: string, value: T): Promise<void> {
return this.tx<void>(store, 'readwrite', (st) =>
this.promisifyRequest<any>(st.put(value as any), store, 'put').then(() => undefined),
);
}
async delete(store: string, key: IDBValidKey): Promise<void> {
return this.tx<void>(store, 'readwrite', (st) =>
this.promisifyRequest<any>(st.delete(key), store, `delete(${String(key)})`).then(
() => undefined,
),
);
}
async clear(store: string): Promise<void> {
return this.tx<void>(store, 'readwrite', (st) =>
this.promisifyRequest<any>(st.clear(), store, 'clear').then(() => undefined),
);
}
async putMany<T>(store: string, values: T[]): Promise<void> {
return this.tx<void>(store, 'readwrite', async (st) => {
for (const v of values) st.put(v as any);
return;
});
}
// Expose helper for advanced callers if needed
promisifyRequest<R>(req: IDBRequest<R>, store: string, action: string): Promise<R> {
return new Promise<R>((resolve, reject) => {
req.onsuccess = () => resolve(req.result as R);
req.onerror = () =>
reject(
new Error(
`IDB ${action} error on ${store}: ${(req.error as any)?.message || (req.error as any)}`,
),
);
});
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Composable for managing user preference for opening project directory.
* Stores the default target (vscode/terminal) in chrome.storage.local.
*/
import { ref, type Ref } from 'vue';
import type { OpenProjectTarget, OpenProjectResponse } from 'chrome-mcp-shared';
// Storage key for default open target
const STORAGE_KEY = 'agent-open-project-default';
export interface UseOpenProjectPreferenceOptions {
/**
* Server port for API calls.
* Should be provided from useAgentServer.
*/
getServerPort: () => number | null;
}
export interface UseOpenProjectPreference {
/** Current default target (null if not set) */
defaultTarget: Ref<OpenProjectTarget | null>;
/** Loading state */
loading: Ref<boolean>;
/** Load default target from storage */
loadDefaultTarget: () => Promise<void>;
/** Save default target to storage */
saveDefaultTarget: (target: OpenProjectTarget) => Promise<void>;
/** Open project by session ID */
openBySession: (sessionId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;
/** Open project by project ID */
openByProject: (projectId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;
}
export function useOpenProjectPreference(
options: UseOpenProjectPreferenceOptions,
): UseOpenProjectPreference {
const defaultTarget = ref<OpenProjectTarget | null>(null);
const loading = ref(false);
/**
* Load default target from chrome.storage.local.
*/
async function loadDefaultTarget(): Promise<void> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY);
const stored = result[STORAGE_KEY];
if (stored === 'vscode' || stored === 'terminal') {
defaultTarget.value = stored;
}
} catch (error) {
console.error('[OpenProjectPreference] Failed to load default target:', error);
}
}
/**
* Save default target to chrome.storage.local.
*/
async function saveDefaultTarget(target: OpenProjectTarget): Promise<void> {
try {
await chrome.storage.local.set({ [STORAGE_KEY]: target });
defaultTarget.value = target;
} catch (error) {
console.error('[OpenProjectPreference] Failed to save default target:', error);
}
}
/**
* Open project directory by session ID.
*/
async function openBySession(
sessionId: string,
target: OpenProjectTarget,
): Promise<OpenProjectResponse> {
const port = options.getServerPort();
if (!port) {
return { success: false, error: 'Server not connected' };
}
loading.value = true;
try {
const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}/open`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
});
const data = (await response.json()) as OpenProjectResponse;
return data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
} finally {
loading.value = false;
}
}
/**
* Open project directory by project ID.
*/
async function openByProject(
projectId: string,
target: OpenProjectTarget,
): Promise<OpenProjectResponse> {
const port = options.getServerPort();
if (!port) {
return { success: false, error: 'Server not connected' };
}
loading.value = true;
try {
const url = `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
});
const data = (await response.json()) as OpenProjectResponse;
return data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
} finally {
loading.value = false;
}
}
return {
defaultTarget,
loading,
loadDefaultTarget,
saveDefaultTarget,
openBySession,
openByProject,
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/download.ts:
--------------------------------------------------------------------------------
```typescript
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
interface HandleDownloadParams {
filenameContains?: string;
timeoutMs?: number; // default 60000
waitForComplete?: boolean; // default true
}
/**
* Tool: wait for a download and return info
*/
class HandleDownloadTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD as any;
async execute(args: HandleDownloadParams): Promise<ToolResult> {
const filenameContains = String(args?.filenameContains || '').trim();
const waitForComplete = args?.waitForComplete !== false;
const timeoutMs = Math.max(1000, Math.min(Number(args?.timeoutMs ?? 60000), 300000));
try {
const result = await waitForDownload({ filenameContains, waitForComplete, timeoutMs });
return {
content: [{ type: 'text', text: JSON.stringify({ success: true, download: result }) }],
isError: false,
};
} catch (e: any) {
return createErrorResponse(`Handle download failed: ${e?.message || String(e)}`);
}
}
}
async function waitForDownload(opts: {
filenameContains?: string;
waitForComplete: boolean;
timeoutMs: number;
}) {
const { filenameContains, waitForComplete, timeoutMs } = opts;
return new Promise<any>((resolve, reject) => {
let timer: any = null;
const onError = (err: any) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
const cleanup = () => {
try {
if (timer) clearTimeout(timer);
} catch {}
try {
chrome.downloads.onCreated.removeListener(onCreated);
} catch {}
try {
chrome.downloads.onChanged.removeListener(onChanged);
} catch {}
};
const matches = (item: chrome.downloads.DownloadItem) => {
if (!filenameContains) return true;
const name = (item.filename || '').split(/[/\\]/).pop() || '';
return name.includes(filenameContains) || (item.url || '').includes(filenameContains);
};
const fulfill = async (item: chrome.downloads.DownloadItem) => {
// try to fill more details via downloads.search
try {
const [found] = await chrome.downloads.search({ id: item.id });
const out = found || item;
cleanup();
resolve({
id: out.id,
filename: out.filename,
url: out.url,
mime: (out as any).mime || undefined,
fileSize: out.fileSize ?? out.totalBytes ?? undefined,
state: out.state,
danger: out.danger,
startTime: out.startTime,
endTime: (out as any).endTime || undefined,
exists: (out as any).exists,
});
return;
} catch {
cleanup();
resolve({ id: item.id, filename: item.filename, url: item.url, state: item.state });
}
};
const onCreated = (item: chrome.downloads.DownloadItem) => {
try {
if (!matches(item)) return;
if (!waitForComplete) {
fulfill(item);
}
} catch {}
};
const onChanged = (delta: chrome.downloads.DownloadDelta) => {
try {
if (!delta || typeof delta.id !== 'number') return;
// pull item and check
chrome.downloads
.search({ id: delta.id })
.then((arr) => {
const item = arr && arr[0];
if (!item) return;
if (!matches(item)) return;
if (waitForComplete && item.state === 'complete') fulfill(item);
})
.catch(() => {});
} catch {}
};
chrome.downloads.onCreated.addListener(onCreated);
chrome.downloads.onChanged.addListener(onChanged);
timer = setTimeout(() => onError(new Error('Download wait timed out')), timeoutMs);
// Try to find an already-running matching download
chrome.downloads
.search({ state: waitForComplete ? 'in_progress' : undefined })
.then((arr) => {
const hit = (arr || []).find((d) => matches(d));
if (hit && !waitForComplete) fulfill(hit);
})
.catch(() => {});
});
}
export const handleDownloadTool = new HandleDownloadTool();
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div class="flex flex-col gap-2">
<!-- Root override -->
<div class="flex items-center gap-2">
<span class="whitespace-nowrap">Root override</span>
<input
:value="projectRoot"
class="flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
placeholder="Optional override path; defaults to selected project workspace"
@input="$emit('update:project-root', ($event.target as HTMLInputElement).value)"
@change="$emit('save-root')"
/>
<button
class="btn-secondary !px-2 !py-1 text-[11px]"
type="button"
:disabled="isSavingRoot"
@click="$emit('save-root')"
>
{{ isSavingRoot ? 'Saving...' : 'Save' }}
</button>
</div>
<!-- CLI & Model selection -->
<div class="flex items-center gap-2">
<span class="whitespace-nowrap">CLI</span>
<select
:value="selectedCli"
class="border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
@change="handleCliChange"
>
<option value="">Auto (per project / server default)</option>
<option v-for="e in engines" :key="e.name" :value="e.name">
{{ e.name }}
</option>
</select>
<span class="whitespace-nowrap">Model</span>
<select
:value="model"
class="flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
@change="$emit('update:model', ($event.target as HTMLSelectElement).value)"
>
<option value="">Default</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.name }}
</option>
</select>
<!-- CCR option (Claude Code Router) - only shown when Claude CLI is selected -->
<label
v-if="showCcrOption"
class="flex items-center gap-1 whitespace-nowrap cursor-pointer"
title="Use Claude Code Router for API routing"
>
<input
type="checkbox"
:checked="useCcr"
class="w-3 h-3 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
@change="$emit('update:use-ccr', ($event.target as HTMLInputElement).checked)"
/>
<span class="text-[11px] text-slate-600">CCR</span>
</label>
<button
class="btn-secondary !px-2 !py-1 text-[11px]"
type="button"
:disabled="!selectedProject || isSavingPreference"
@click="$emit('save-preference')"
>
{{ isSavingPreference ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';
import {
getModelsForCli,
getDefaultModelForCli,
type ModelDefinition,
} from '@/common/agent-models';
const props = defineProps<{
projectRoot: string;
selectedCli: string;
model: string;
useCcr: boolean;
engines: AgentEngineInfo[];
selectedProject: AgentProject | null;
isSavingRoot: boolean;
isSavingPreference: boolean;
}>();
const emit = defineEmits<{
'update:project-root': [value: string];
'update:selected-cli': [value: string];
'update:model': [value: string];
'update:use-ccr': [value: boolean];
'save-root': [];
'save-preference': [];
}>();
// Get available models based on selected CLI
const availableModels = computed<ModelDefinition[]>(() => {
return getModelsForCli(props.selectedCli);
});
// Show CCR option only when Claude CLI is selected
const showCcrOption = computed(() => {
return props.selectedCli === 'claude';
});
// Handle CLI change - auto-select default model for the CLI
function handleCliChange(event: Event): void {
const cli = (event.target as HTMLSelectElement).value;
emit('update:selected-cli', cli);
// Auto-select default model when CLI changes
if (cli) {
const defaultModel = getDefaultModelForCli(cli);
emit('update:model', defaultModel);
} else {
emit('update:model', '');
}
// Reset CCR when switching away from Claude
if (cli !== 'claude') {
emit('update:use-ccr', false);
}
}
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Command Trigger Handler (P4-04)
* @description
* Listens to `chrome.commands.onCommand` and fires installed command triggers.
*
* Command triggers allow users to execute flows via keyboard shortcuts
* defined in the extension's manifest.
*
* Design notes:
* - Commands must be registered in manifest.json under the "commands" key
* - Each command is identified by its commandKey (e.g., "run-flow-1")
* - Active tab info is captured when available
*/
import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
// ==================== Types ====================
export interface CommandTriggerHandlerDeps {
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}
type CommandTriggerSpec = TriggerSpecByKind<'command'>;
interface InstalledCommandTrigger {
spec: CommandTriggerSpec;
}
// ==================== Handler Implementation ====================
/**
* Create command trigger handler factory
*/
export function createCommandTriggerHandlerFactory(
deps?: CommandTriggerHandlerDeps,
): TriggerHandlerFactory<'command'> {
return (fireCallback) => createCommandTriggerHandler(fireCallback, deps);
}
/**
* Create command trigger handler
*/
export function createCommandTriggerHandler(
fireCallback: TriggerFireCallback,
deps?: CommandTriggerHandlerDeps,
): TriggerHandler<'command'> {
const logger = deps?.logger ?? console;
// Map commandKey -> triggerId for fast lookup
const commandKeyToTriggerId = new Map<string, TriggerId>();
const installed = new Map<TriggerId, InstalledCommandTrigger>();
let listening = false;
/**
* Handle chrome.commands.onCommand event
*/
const onCommand = (command: string, tab?: chrome.tabs.Tab): void => {
const triggerId = commandKeyToTriggerId.get(command);
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: tab?.url,
}),
).catch((e) => {
logger.error(`[CommandTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
});
};
/**
* Ensure listener is registered
*/
function ensureListening(): void {
if (listening) return;
if (!chrome.commands?.onCommand?.addListener) {
logger.warn('[CommandTriggerHandler] chrome.commands.onCommand is unavailable');
return;
}
chrome.commands.onCommand.addListener(onCommand);
listening = true;
}
/**
* Stop listening
*/
function stopListening(): void {
if (!listening) return;
try {
chrome.commands.onCommand.removeListener(onCommand);
} catch (e) {
logger.debug('[CommandTriggerHandler] removeListener failed:', e);
} finally {
listening = false;
}
}
return {
kind: 'command',
async install(trigger: CommandTriggerSpec): Promise<void> {
const { id, commandKey } = trigger;
// Warn if commandKey already used by another trigger
const existingTriggerId = commandKeyToTriggerId.get(commandKey);
if (existingTriggerId && existingTriggerId !== id) {
logger.warn(
`[CommandTriggerHandler] Command "${commandKey}" already used by trigger "${existingTriggerId}", overwriting with "${id}"`,
);
// Remove old mapping
installed.delete(existingTriggerId);
}
installed.set(id, { spec: trigger });
commandKeyToTriggerId.set(commandKey, id);
ensureListening();
},
async uninstall(triggerId: string): Promise<void> {
const trigger = installed.get(triggerId as TriggerId);
if (trigger) {
commandKeyToTriggerId.delete(trigger.spec.commandKey);
installed.delete(triggerId as TriggerId);
}
if (installed.size === 0) {
stopListening();
}
},
async uninstallAll(): Promise<void> {
installed.clear();
commandKeyToTriggerId.clear();
stopListening();
},
getInstalledIds(): string[] {
return Array.from(installed.keys());
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Composable for textarea auto-resize functionality.
* Automatically adjusts textarea height based on content while respecting min/max constraints.
*/
import { ref, watch, nextTick, onMounted, onUnmounted, type Ref } from 'vue';
export interface UseTextareaAutoResizeOptions {
/** Ref to the textarea element */
textareaRef: Ref<HTMLTextAreaElement | null>;
/** Ref to the textarea value (for watching changes) */
value: Ref<string>;
/** Minimum height in pixels */
minHeight?: number;
/** Maximum height in pixels */
maxHeight?: number;
}
export interface UseTextareaAutoResizeReturn {
/** Current calculated height */
height: Ref<number>;
/** Whether content exceeds max height (textarea is overflowing) */
isOverflowing: Ref<boolean>;
/** Manually trigger height recalculation */
recalculate: () => void;
}
const DEFAULT_MIN_HEIGHT = 50;
const DEFAULT_MAX_HEIGHT = 200;
/**
* Composable for auto-resizing textarea based on content.
*
* Features:
* - Automatically adjusts height on input
* - Respects min/max height constraints
* - Handles width changes (line wrapping affects height)
* - Uses requestAnimationFrame for performance
*/
export function useTextareaAutoResize(
options: UseTextareaAutoResizeOptions,
): UseTextareaAutoResizeReturn {
const {
textareaRef,
value,
minHeight = DEFAULT_MIN_HEIGHT,
maxHeight = DEFAULT_MAX_HEIGHT,
} = options;
const height = ref<number>(minHeight);
const isOverflowing = ref(false);
let scheduled = false;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
/**
* Calculate textarea height based on content.
* Only updates the reactive `height` and `isOverflowing` refs.
* The actual DOM height is controlled via :style binding in the template.
*/
function recalculate(): void {
const el = textareaRef.value;
if (!el) return;
// Temporarily set height to 'auto' to get accurate scrollHeight
// Save current height to minimize visual flicker
const currentHeight = el.style.height;
el.style.height = 'auto';
const contentHeight = el.scrollHeight;
const clampedHeight = Math.min(maxHeight, Math.max(minHeight, contentHeight));
// Restore height immediately (the actual height is controlled by Vue binding)
el.style.height = currentHeight;
// Update reactive state
height.value = clampedHeight;
// Add small tolerance (1px) to account for rounding
isOverflowing.value = contentHeight > maxHeight + 1;
}
/**
* Schedule height recalculation using requestAnimationFrame.
* Batches multiple calls within the same frame for performance.
*/
function scheduleRecalculate(): void {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
recalculate();
});
}
// Watch value changes
watch(
value,
async () => {
await nextTick();
scheduleRecalculate();
},
{ flush: 'post' },
);
// Watch textarea ref changes (in case it's replaced)
watch(
textareaRef,
async (newEl, oldEl) => {
// Cleanup old observer
if (resizeObserver && oldEl) {
resizeObserver.unobserve(oldEl);
}
if (!newEl) return;
await nextTick();
scheduleRecalculate();
// Setup new observer for width changes
if (resizeObserver) {
lastWidth = newEl.offsetWidth;
resizeObserver.observe(newEl);
}
},
{ immediate: true },
);
onMounted(() => {
const el = textareaRef.value;
if (!el) return;
// Initial calculation
scheduleRecalculate();
// Setup ResizeObserver for width changes
// Width changes affect line wrapping, which affects scrollHeight
if (typeof ResizeObserver !== 'undefined') {
lastWidth = el.offsetWidth;
resizeObserver = new ResizeObserver(() => {
const current = textareaRef.value;
if (!current) return;
const currentWidth = current.offsetWidth;
if (currentWidth !== lastWidth) {
lastWidth = currentWidth;
scheduleRecalculate();
}
});
resizeObserver.observe(el);
}
});
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = null;
});
return {
height,
isOverflowing,
recalculate,
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Composable for managing AgentChat theme.
* Handles theme persistence and application.
*/
import { ref, type Ref } from 'vue';
/** Available theme identifiers */
export type AgentThemeId =
| 'warm-editorial'
| 'blueprint-architect'
| 'zen-journal'
| 'neo-pop'
| 'dark-console'
| 'swiss-grid';
/** Storage key for persisting theme preference */
const STORAGE_KEY_THEME = 'agentTheme';
/** Default theme when none is set */
const DEFAULT_THEME: AgentThemeId = 'warm-editorial';
/** Valid theme IDs for validation */
const VALID_THEMES: AgentThemeId[] = [
'warm-editorial',
'blueprint-architect',
'zen-journal',
'neo-pop',
'dark-console',
'swiss-grid',
];
/** Theme display names for UI */
export const THEME_LABELS: Record<AgentThemeId, string> = {
'warm-editorial': 'Editorial',
'blueprint-architect': 'Blueprint',
'zen-journal': 'Zen',
'neo-pop': 'Neo-Pop',
'dark-console': 'Console',
'swiss-grid': 'Swiss',
};
export interface UseAgentTheme {
/** Current theme ID */
theme: Ref<AgentThemeId>;
/** Whether theme has been loaded from storage */
ready: Ref<boolean>;
/** Set and persist a new theme */
setTheme: (id: AgentThemeId) => Promise<void>;
/** Load theme from storage (call on mount) */
initTheme: () => Promise<void>;
/** Apply theme to a DOM element */
applyTo: (el: HTMLElement) => void;
/** Get the preloaded theme from document (set by main.ts) */
getPreloadedTheme: () => AgentThemeId;
}
/**
* Check if a string is a valid theme ID
*/
function isValidTheme(value: unknown): value is AgentThemeId {
return typeof value === 'string' && VALID_THEMES.includes(value as AgentThemeId);
}
/**
* Get theme from document element (preloaded by main.ts)
*/
function getThemeFromDocument(): AgentThemeId {
const value = document.documentElement.dataset.agentTheme;
return isValidTheme(value) ? value : DEFAULT_THEME;
}
/**
* Composable for managing AgentChat theme
*/
export function useAgentTheme(): UseAgentTheme {
// Initialize with preloaded theme (or default)
const theme = ref<AgentThemeId>(getThemeFromDocument());
const ready = ref(false);
/**
* Load theme from chrome.storage.local
*/
async function initTheme(): Promise<void> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY_THEME);
const stored = result[STORAGE_KEY_THEME];
if (isValidTheme(stored)) {
theme.value = stored;
} else {
// Use preloaded or default
theme.value = getThemeFromDocument();
}
} catch (error) {
console.error('[useAgentTheme] Failed to load theme:', error);
theme.value = getThemeFromDocument();
} finally {
ready.value = true;
}
}
/**
* Set and persist a new theme
*/
async function setTheme(id: AgentThemeId): Promise<void> {
if (!isValidTheme(id)) {
console.warn('[useAgentTheme] Invalid theme ID:', id);
return;
}
// Update immediately for responsive UI
theme.value = id;
// Also update document element for consistency
document.documentElement.dataset.agentTheme = id;
// Persist to storage
try {
await chrome.storage.local.set({ [STORAGE_KEY_THEME]: id });
} catch (error) {
console.error('[useAgentTheme] Failed to save theme:', error);
}
}
/**
* Apply theme to a DOM element
*/
function applyTo(el: HTMLElement): void {
el.dataset.agentTheme = theme.value;
}
/**
* Get the preloaded theme from document
*/
function getPreloadedTheme(): AgentThemeId {
return getThemeFromDocument();
}
return {
theme,
ready,
setTheme,
initTheme,
applyTo,
getPreloadedTheme,
};
}
/**
* Preload theme before Vue mounts (call in main.ts)
* This prevents theme flashing on page load.
*/
export async function preloadAgentTheme(): Promise<AgentThemeId> {
let themeId: AgentThemeId = DEFAULT_THEME;
try {
const result = await chrome.storage.local.get(STORAGE_KEY_THEME);
const stored = result[STORAGE_KEY_THEME];
if (isValidTheme(stored)) {
themeId = stored;
}
} catch (error) {
console.error('[preloadAgentTheme] Failed to load theme:', error);
}
// Set on document element for immediate application
document.documentElement.dataset.agentTheme = themeId;
return themeId;
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay-v3/manual-trigger.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview Manual Trigger Handler 测试 (P4-08)
* @description
* Tests for:
* - Basic install/uninstall operations
* - getInstalledIds tracking
*/
import { describe, expect, it, vi } from 'vitest';
import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
import { createManualTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger';
// ==================== Test Utilities ====================
function createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
// ==================== Manual Trigger Tests ====================
describe('V3 ManualTriggerHandler', () => {
describe('Installation', () => {
it('installs trigger', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
const trigger: TriggerSpecByKind<'manual'> = {
id: 't1' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-1' as never,
};
await handler.install(trigger);
expect(handler.getInstalledIds()).toEqual(['t1']);
});
it('installs multiple triggers', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
await handler.install({
id: 't1' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-1' as never,
});
await handler.install({
id: 't2' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-2' as never,
});
expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);
});
});
describe('Uninstallation', () => {
it('uninstalls trigger', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
await handler.install({
id: 't1' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-1' as never,
});
await handler.uninstall('t1');
expect(handler.getInstalledIds()).toEqual([]);
});
it('uninstallAll clears all triggers', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
await handler.install({
id: 't1' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-1' as never,
});
await handler.install({
id: 't2' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-2' as never,
});
await handler.uninstallAll();
expect(handler.getInstalledIds()).toEqual([]);
});
});
describe('getInstalledIds', () => {
it('returns empty array when no triggers installed', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
expect(handler.getInstalledIds()).toEqual([]);
});
it('tracks partial uninstall', async () => {
const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
fireCallback,
);
await handler.install({
id: 't1' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-1' as never,
});
await handler.install({
id: 't2' as never,
kind: 'manual',
enabled: true,
flowId: 'flow-2' as never,
});
await handler.uninstall('t1');
expect(handler.getInstalledIds()).toEqual(['t2']);
});
});
});
```
--------------------------------------------------------------------------------
/app/native-server/src/agent/directory-picker.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Directory Picker Service.
*
* Provides cross-platform directory selection using native system dialogs.
* Uses platform-specific commands:
* - macOS: osascript (AppleScript)
* - Windows: PowerShell
* - Linux: zenity or kdialog
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
const execAsync = promisify(exec);
export interface DirectoryPickerResult {
success: boolean;
path?: string;
cancelled?: boolean;
error?: string;
}
/**
* Open a native directory picker dialog.
* Returns the selected directory path or indicates cancellation.
*/
export async function openDirectoryPicker(
title = 'Select Project Directory',
): Promise<DirectoryPickerResult> {
const platform = os.platform();
try {
switch (platform) {
case 'darwin':
return await openMacOSPicker(title);
case 'win32':
return await openWindowsPicker(title);
case 'linux':
return await openLinuxPicker(title);
default:
return {
success: false,
error: `Unsupported platform: ${platform}`,
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* macOS: Use osascript to open Finder folder picker.
*/
async function openMacOSPicker(title: string): Promise<DirectoryPickerResult> {
const script = `
set selectedFolder to choose folder with prompt "${title}"
return POSIX path of selectedFolder
`;
try {
const { stdout } = await execAsync(`osascript -e '${script}'`);
const path = stdout.trim();
if (path) {
return { success: true, path };
}
return { success: false, cancelled: true };
} catch (error) {
// User cancelled returns error code 1
const err = error as { code?: number; stderr?: string };
if (err.code === 1) {
return { success: false, cancelled: true };
}
throw error;
}
}
/**
* Windows: Use PowerShell to open folder browser dialog.
*/
async function openWindowsPicker(title: string): Promise<DirectoryPickerResult> {
const psScript = `
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = "${title}"
$dialog.ShowNewFolderButton = $true
$result = $dialog.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dialog.SelectedPath
}
`;
// Escape for command line
const escapedScript = psScript.replace(/"/g, '\\"').replace(/\n/g, ' ');
try {
const { stdout } = await execAsync(
`powershell -NoProfile -Command "${escapedScript}"`,
{ timeout: 60000 }, // 60 second timeout
);
const path = stdout.trim();
if (path) {
return { success: true, path };
}
return { success: false, cancelled: true };
} catch (error) {
const err = error as { killed?: boolean };
if (err.killed) {
return { success: false, error: 'Dialog timed out' };
}
throw error;
}
}
/**
* Linux: Try zenity first, then kdialog as fallback.
*/
async function openLinuxPicker(title: string): Promise<DirectoryPickerResult> {
// Try zenity first (GTK)
try {
const { stdout } = await execAsync(`zenity --file-selection --directory --title="${title}"`, {
timeout: 60000,
});
const path = stdout.trim();
if (path) {
return { success: true, path };
}
return { success: false, cancelled: true };
} catch (zenityError) {
// zenity returns exit code 1 on cancel, 5 if not installed
const err = zenityError as { code?: number };
if (err.code === 1) {
return { success: false, cancelled: true };
}
// Try kdialog as fallback (KDE)
try {
const { stdout } = await execAsync(`kdialog --getexistingdirectory ~ --title "${title}"`, {
timeout: 60000,
});
const path = stdout.trim();
if (path) {
return { success: true, path };
}
return { success: false, cancelled: true };
} catch (kdialogError) {
const kdErr = kdialogError as { code?: number };
if (kdErr.code === 1) {
return { success: false, cancelled: true };
}
return {
success: false,
error: 'No directory picker available. Please install zenity or kdialog.',
};
}
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/SelectionChip.vue:
--------------------------------------------------------------------------------
```vue
<template>
<div
ref="chipRef"
class="relative inline-flex items-center gap-1.5 text-[11px] leading-none flex-shrink-0 select-none"
:style="chipStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- Selection Icon -->
<span class="inline-flex items-center justify-center w-3.5 h-3.5" :style="iconStyle">
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
</span>
<!-- Element Label (tagName only) -->
<span class="truncate max-w-[140px] px-1 py-0.5" :style="labelStyle">
{{ chipTagName }}
</span>
<!-- "Selected" Indicator -->
<span class="px-1 py-0.5 text-[9px] uppercase tracking-wider" :style="pillStyle"> sel </span>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onUnmounted } from 'vue';
import type { SelectedElementSummary } from '@/common/web-editor-types';
// =============================================================================
// Props & Emits
// =============================================================================
const props = defineProps<{
/** Selected element summary to display */
selected: SelectedElementSummary;
}>();
const emit = defineEmits<{
/** Mouse enter - start highlight */
'hover:start': [selected: SelectedElementSummary];
/** Mouse leave - clear highlight */
'hover:end': [selected: SelectedElementSummary];
}>();
// =============================================================================
// Local State
// =============================================================================
const chipRef = ref<HTMLDivElement | null>(null);
const isHovering = ref(false);
// =============================================================================
// Computed: UI State
// =============================================================================
/**
* Use tagName for compact chip display.
* Falls back to extracting from label if tagName is not available.
*/
const chipTagName = computed(() => {
// First try explicit tagName
if (props.selected.tagName) {
return props.selected.tagName.toLowerCase();
}
// Fallback: extract from label
const label = (props.selected.label || '').trim();
const match = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
return match?.[1]?.toLowerCase() || 'element';
});
// =============================================================================
// Computed: Styles
// =============================================================================
const chipStyle = computed(() => ({
backgroundColor: isHovering.value ? 'var(--ac-hover-bg)' : 'var(--ac-surface)',
border: `var(--ac-border-width) solid ${isHovering.value ? 'var(--ac-accent)' : 'var(--ac-border)'}`,
borderRadius: 'var(--ac-radius-button)',
boxShadow: isHovering.value ? 'var(--ac-shadow-card)' : 'none',
color: 'var(--ac-text)',
cursor: 'default',
}));
const iconStyle = computed(() => ({
color: 'var(--ac-accent)',
}));
const labelStyle = computed(() => ({
fontFamily: 'var(--ac-font-mono)',
}));
const pillStyle = computed(() => ({
backgroundColor: 'var(--ac-accent)',
color: 'var(--ac-accent-contrast)',
borderRadius: 'var(--ac-radius-button)',
fontFamily: 'var(--ac-font-mono)',
fontWeight: '600',
}));
// =============================================================================
// Event Handlers
// =============================================================================
function handleMouseEnter(): void {
isHovering.value = true;
emit('hover:start', props.selected);
}
function handleMouseLeave(): void {
isHovering.value = false;
emit('hover:end', props.selected);
}
// =============================================================================
// Lifecycle
// =============================================================================
onUnmounted(() => {
// Clear any active highlight when chip is unmounted
// (e.g., when selection changes or element appears in edits)
if (isHovering.value) {
emit('hover:end', props.selected);
}
});
</script>
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/style.css:
--------------------------------------------------------------------------------
```css
/* 现代化全局样式 */
:root {
/* 字体系统 */
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
font-weight: 400;
/* 颜色系统 */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #667eea;
--primary-dark: #5a67d8;
--secondary-color: #764ba2;
--success-color: #48bb78;
--warning-color: #ed8936;
--error-color: #f56565;
--info-color: #4299e1;
--text-primary: #2d3748;
--text-secondary: #4a5568;
--text-muted: #718096;
--text-light: #a0aec0;
--bg-primary: #ffffff;
--bg-secondary: #f7fafc;
--bg-tertiary: #edf2f7;
--bg-overlay: rgba(255, 255, 255, 0.95);
--border-color: #e2e8f0;
--border-light: #f1f5f9;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* 间距系统 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--spacing-3xl: 32px;
/* 圆角系统 */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
/* 动画 */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* 字体渲染优化 */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/* 重置样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
padding: 0;
width: 400px;
min-height: 500px;
max-height: 600px;
overflow: hidden;
font-family: inherit;
background: var(--bg-secondary);
color: var(--text-primary);
}
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--primary-dark);
}
/* 按钮基础样式重置 */
button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
background: none;
cursor: pointer;
transition: all var(--transition-normal);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 输入框基础样式 */
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-primary);
color: var(--text-primary);
transition: all var(--transition-fast);
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* 选择文本样式 */
::selection {
background: rgba(102, 126, 234, 0.2);
color: var(--text-primary);
}
/* 焦点可见性 */
:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* 动画关键帧 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 响应式断点 */
@media (max-width: 420px) {
:root {
--spacing-xs: 3px;
--spacing-sm: 6px;
--spacing-md: 10px;
--spacing-lg: 14px;
--spacing-xl: 18px;
--spacing-2xl: 22px;
--spacing-3xl: 28px;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
:root {
--border-color: #000000;
--text-muted: #000000;
}
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts:
--------------------------------------------------------------------------------
```typescript
/**
* @fileoverview RunEvent 持久化
* @description 实现事件的原子 seq 分配和存储
*/
import type { RunId } from '../domain/ids';
import type { RunEvent, RunEventInput, RunRecordV3 } from '../domain/events';
import { RR_ERROR_CODES, createRRError } from '../domain/errors';
import type { EventsStore } from '../engine/storage/storage-port';
import { RR_V3_STORES, withTransaction } from './db';
/**
* IDB request helper - promisify IDBRequest with RRError wrapping
*/
function idbRequest<T>(request: IDBRequest<T>, context: string): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
const error = request.error;
reject(
createRRError(
RR_ERROR_CODES.INTERNAL,
`IDB error in ${context}: ${error?.message ?? 'unknown'}`,
),
);
};
});
}
/**
* 创建 EventsStore 实现
* @description
* - append() 在单个事务中原子分配 seq
* - seq 由 RunRecordV3.nextSeq 作为单一事实来源
*/
export function createEventsStore(): EventsStore {
return {
/**
* 追加事件并原子分配 seq
* @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq
*/
async append(input: RunEventInput): Promise<RunEvent> {
return withTransaction(
[RR_V3_STORES.RUNS, RR_V3_STORES.EVENTS],
'readwrite',
async (stores) => {
const runsStore = stores[RR_V3_STORES.RUNS];
const eventsStore = stores[RR_V3_STORES.EVENTS];
// Step 1: Read nextSeq from RunRecordV3 (single source of truth)
const run = await idbRequest<RunRecordV3 | undefined>(
runsStore.get(input.runId),
`append.getRun(${input.runId})`,
);
if (!run) {
throw createRRError(
RR_ERROR_CODES.INTERNAL,
`Run "${input.runId}" not found when appending event`,
);
}
const seq = run.nextSeq;
// Validate seq integrity
if (!Number.isSafeInteger(seq) || seq < 0) {
throw createRRError(
RR_ERROR_CODES.INVARIANT_VIOLATION,
`Invalid nextSeq for run "${input.runId}": ${String(seq)}`,
);
}
// Step 2: Create complete event with allocated seq
const event: RunEvent = {
...input,
seq,
ts: input.ts ?? Date.now(),
} as RunEvent;
// Step 3: Write event to events store
await idbRequest(eventsStore.add(event), `append.addEvent(${input.runId}, seq=${seq})`);
// Step 4: Increment nextSeq in runs store (same transaction)
const updatedRun: RunRecordV3 = {
...run,
nextSeq: seq + 1,
updatedAt: Date.now(),
};
await idbRequest(
runsStore.put(updatedRun),
`append.updateNextSeq(${input.runId}, nextSeq=${seq + 1})`,
);
return event;
},
);
},
/**
* 列出事件
* @description 利用复合主键 [runId, seq] 实现高效范围查询
*/
async list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]> {
return withTransaction(RR_V3_STORES.EVENTS, 'readonly', async (stores) => {
const store = stores[RR_V3_STORES.EVENTS];
const fromSeq = opts?.fromSeq ?? 0;
const limit = opts?.limit;
// Early return for zero limit
if (limit === 0) {
return [];
}
return new Promise<RunEvent[]>((resolve, reject) => {
const results: RunEvent[] = [];
// Use compound primary key [runId, seq] for efficient range query
// This yields events in seq-ascending order naturally
const range = IDBKeyRange.bound([runId, fromSeq], [runId, Number.MAX_SAFE_INTEGER]);
const request = store.openCursor(range);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(results);
return;
}
const event = cursor.value as RunEvent;
results.push(event);
// Check limit
if (limit !== undefined && results.length >= limit) {
resolve(results);
return;
}
cursor.continue();
};
request.onerror = () => reject(request.error);
});
});
},
};
}
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/slider-input.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Slider Input Component
*
* A reusable "slider + input" control for numeric values:
* - Left: native range slider for visual manipulation
* - Right: InputContainer-backed numeric input for precise values
*
* Features:
* - Bidirectional synchronization between slider and input
* - Supports disabled state
* - Accessible with ARIA labels
*
* Styling is defined in shadow-host.ts:
* - `.we-slider-input`
* - `.we-slider-input__slider`
* - `.we-slider-input__number`
*/
import { createInputContainer, type InputContainer } from './input-container';
// =============================================================================
// Types
// =============================================================================
export interface SliderInputOptions {
/** Accessible label for the range slider */
sliderAriaLabel: string;
/** Accessible label for the numeric input */
inputAriaLabel: string;
/** Minimum value for the slider */
min: number;
/** Maximum value for the slider */
max: number;
/** Step increment for the slider */
step: number;
/** Input mode for the numeric input (default: "decimal") */
inputMode?: string;
/** Fixed width for the numeric input in pixels (default: 72) */
inputWidthPx?: number;
}
export interface SliderInput {
/** Root container element */
root: HTMLDivElement;
/** Range slider element */
slider: HTMLInputElement;
/** Numeric input element */
input: HTMLInputElement;
/** Input container instance for advanced customization */
inputContainer: InputContainer;
/** Set disabled state for both controls */
setDisabled(disabled: boolean): void;
/** Set disabled state for slider only */
setSliderDisabled(disabled: boolean): void;
/** Set value for both controls */
setValue(value: number): void;
/** Set slider value only (without affecting input) */
setSliderValue(value: number): void;
}
// =============================================================================
// Factory
// =============================================================================
/**
* Create a slider input component with synchronized slider and input
*/
export function createSliderInput(options: SliderInputOptions): SliderInput {
const {
sliderAriaLabel,
inputAriaLabel,
min,
max,
step,
inputMode = 'decimal',
inputWidthPx = 72,
} = options;
// Root container
const root = document.createElement('div');
root.className = 'we-slider-input';
// Range slider
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'we-slider-input__slider';
slider.min = String(min);
slider.max = String(max);
slider.step = String(step);
slider.value = String(min);
slider.setAttribute('aria-label', sliderAriaLabel);
/**
* Update the slider's progress color based on current value.
* Uses CSS custom property --progress for the gradient.
*/
function updateSliderProgress(): void {
const value = parseFloat(slider.value);
const minVal = parseFloat(slider.min);
const maxVal = parseFloat(slider.max);
const percent = ((value - minVal) / (maxVal - minVal)) * 100;
slider.style.setProperty('--progress', `${percent}%`);
}
// Initialize progress
updateSliderProgress();
// Update progress on input
slider.addEventListener('input', updateSliderProgress);
// Numeric input using InputContainer
const inputContainer = createInputContainer({
ariaLabel: inputAriaLabel,
inputMode,
prefix: null,
suffix: null,
rootClassName: 'we-slider-input__number',
});
inputContainer.root.style.width = `${inputWidthPx}px`;
inputContainer.root.style.flex = '0 0 auto';
root.append(slider, inputContainer.root);
// Public methods
function setDisabled(disabled: boolean): void {
slider.disabled = disabled;
inputContainer.input.disabled = disabled;
}
function setSliderDisabled(disabled: boolean): void {
slider.disabled = disabled;
}
function setValue(value: number): void {
const stringValue = String(value);
slider.value = stringValue;
inputContainer.input.value = stringValue;
updateSliderProgress();
}
function setSliderValue(value: number): void {
slider.value = String(value);
updateSliderProgress();
}
return {
root,
slider,
input: inputContainer.input,
inputContainer,
setDisabled,
setSliderDisabled,
setValue,
setSliderValue,
};
}
```