#
tokens: 49554/50000 24/574 files (page 5/43)
lines: off (toggle) GitHub
raw markdown copy
This is page 5 of 43. Use http://codebase.md/hangwin/mcp-chrome?lines=false&page={x} to view the full context.

# Directory Structure

```
├── .gitattributes
├── .github
│   └── workflows
│       └── build-release.yml
├── .gitignore
├── .husky
│   ├── commit-msg
│   └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│   └── extensions.json
├── app
│   ├── chrome-extension
│   │   ├── _locales
│   │   │   ├── de
│   │   │   │   └── messages.json
│   │   │   ├── en
│   │   │   │   └── messages.json
│   │   │   ├── ja
│   │   │   │   └── messages.json
│   │   │   ├── ko
│   │   │   │   └── messages.json
│   │   │   ├── zh_CN
│   │   │   │   └── messages.json
│   │   │   └── zh_TW
│   │   │       └── messages.json
│   │   ├── .env.example
│   │   ├── assets
│   │   │   └── vue.svg
│   │   ├── common
│   │   │   ├── agent-models.ts
│   │   │   ├── constants.ts
│   │   │   ├── element-marker-types.ts
│   │   │   ├── message-types.ts
│   │   │   ├── node-types.ts
│   │   │   ├── rr-v3-keepalive-protocol.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tool-handler.ts
│   │   │   └── web-editor-types.ts
│   │   ├── entrypoints
│   │   │   ├── background
│   │   │   │   ├── element-marker
│   │   │   │   │   ├── element-marker-storage.ts
│   │   │   │   │   └── index.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── keepalive-manager.ts
│   │   │   │   ├── native-host.ts
│   │   │   │   ├── quick-panel
│   │   │   │   │   ├── agent-handler.ts
│   │   │   │   │   ├── commands.ts
│   │   │   │   │   └── tabs-handler.ts
│   │   │   │   ├── record-replay
│   │   │   │   │   ├── actions
│   │   │   │   │   │   ├── adapter.ts
│   │   │   │   │   │   ├── handlers
│   │   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   │   ├── control-flow.ts
│   │   │   │   │   │   │   ├── delay.ts
│   │   │   │   │   │   │   ├── dom.ts
│   │   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── constants.ts
│   │   │   │   │   │   ├── execution-mode.ts
│   │   │   │   │   │   ├── logging
│   │   │   │   │   │   │   └── run-logger.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── breakpoint.ts
│   │   │   │   │   │   │   ├── manager.ts
│   │   │   │   │   │   │   └── types.ts
│   │   │   │   │   │   ├── policies
│   │   │   │   │   │   │   ├── retry.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── runners
│   │   │   │   │   │   │   ├── after-script-queue.ts
│   │   │   │   │   │   │   ├── control-flow-runner.ts
│   │   │   │   │   │   │   ├── step-executor.ts
│   │   │   │   │   │   │   ├── step-runner.ts
│   │   │   │   │   │   │   └── subflow-runner.ts
│   │   │   │   │   │   ├── scheduler.ts
│   │   │   │   │   │   ├── state-manager.ts
│   │   │   │   │   │   └── utils
│   │   │   │   │   │       └── expression.ts
│   │   │   │   │   ├── flow-runner.ts
│   │   │   │   │   ├── flow-store.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── legacy-types.ts
│   │   │   │   │   ├── nodes
│   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   ├── conditional.ts
│   │   │   │   │   │   ├── download-screenshot-attr-event-frame-loop.ts
│   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   ├── execute-flow.ts
│   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   ├── loops.ts
│   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   └── wait.ts
│   │   │   │   │   ├── recording
│   │   │   │   │   │   ├── browser-event-listener.ts
│   │   │   │   │   │   ├── content-injection.ts
│   │   │   │   │   │   ├── content-message-handler.ts
│   │   │   │   │   │   ├── flow-builder.ts
│   │   │   │   │   │   ├── recorder-manager.ts
│   │   │   │   │   │   └── session-manager.ts
│   │   │   │   │   ├── rr-utils.ts
│   │   │   │   │   ├── selector-engine.ts
│   │   │   │   │   ├── storage
│   │   │   │   │   │   └── indexeddb-manager.ts
│   │   │   │   │   ├── trigger-store.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── record-replay-v3
│   │   │   │   │   ├── bootstrap.ts
│   │   │   │   │   ├── domain
│   │   │   │   │   │   ├── debug.ts
│   │   │   │   │   │   ├── errors.ts
│   │   │   │   │   │   ├── events.ts
│   │   │   │   │   │   ├── flow.ts
│   │   │   │   │   │   ├── ids.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── json.ts
│   │   │   │   │   │   ├── policy.ts
│   │   │   │   │   │   ├── triggers.ts
│   │   │   │   │   │   └── variables.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── keepalive
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── offscreen-keepalive.ts
│   │   │   │   │   │   ├── kernel
│   │   │   │   │   │   │   ├── artifacts.ts
│   │   │   │   │   │   │   ├── breakpoints.ts
│   │   │   │   │   │   │   ├── debug-controller.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── kernel.ts
│   │   │   │   │   │   │   ├── recovery-kernel.ts
│   │   │   │   │   │   │   ├── runner.ts
│   │   │   │   │   │   │   └── traversal.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── register-v2-replay-nodes.ts
│   │   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   │   └── v2-action-adapter.ts
│   │   │   │   │   │   ├── queue
│   │   │   │   │   │   │   ├── enqueue-run.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── leasing.ts
│   │   │   │   │   │   │   ├── queue.ts
│   │   │   │   │   │   │   └── scheduler.ts
│   │   │   │   │   │   ├── recovery
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── recovery-coordinator.ts
│   │   │   │   │   │   ├── storage
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── storage-port.ts
│   │   │   │   │   │   ├── transport
│   │   │   │   │   │   │   ├── events-bus.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── rpc-server.ts
│   │   │   │   │   │   │   └── rpc.ts
│   │   │   │   │   │   └── triggers
│   │   │   │   │   │       ├── command-trigger.ts
│   │   │   │   │   │       ├── context-menu-trigger.ts
│   │   │   │   │   │       ├── cron-trigger.ts
│   │   │   │   │   │       ├── dom-trigger.ts
│   │   │   │   │   │       ├── index.ts
│   │   │   │   │   │       ├── interval-trigger.ts
│   │   │   │   │   │       ├── manual-trigger.ts
│   │   │   │   │   │       ├── once-trigger.ts
│   │   │   │   │   │       ├── trigger-handler.ts
│   │   │   │   │   │       ├── trigger-manager.ts
│   │   │   │   │   │       └── url-trigger.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── storage
│   │   │   │   │       ├── db.ts
│   │   │   │   │       ├── events.ts
│   │   │   │   │       ├── flows.ts
│   │   │   │   │       ├── import
│   │   │   │   │       │   ├── index.ts
│   │   │   │   │       │   ├── v2-reader.ts
│   │   │   │   │       │   └── v2-to-v3.ts
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── persistent-vars.ts
│   │   │   │   │       ├── queue.ts
│   │   │   │   │       ├── runs.ts
│   │   │   │   │       └── triggers.ts
│   │   │   │   ├── semantic-similarity.ts
│   │   │   │   ├── storage-manager.ts
│   │   │   │   ├── tools
│   │   │   │   │   ├── base-browser.ts
│   │   │   │   │   ├── browser
│   │   │   │   │   │   ├── bookmark.ts
│   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   ├── computer.ts
│   │   │   │   │   │   ├── console-buffer.ts
│   │   │   │   │   │   ├── console.ts
│   │   │   │   │   │   ├── dialog.ts
│   │   │   │   │   │   ├── download.ts
│   │   │   │   │   │   ├── element-picker.ts
│   │   │   │   │   │   ├── file-upload.ts
│   │   │   │   │   │   ├── gif-auto-capture.ts
│   │   │   │   │   │   ├── gif-enhanced-renderer.ts
│   │   │   │   │   │   ├── gif-recorder.ts
│   │   │   │   │   │   ├── history.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── inject-script.ts
│   │   │   │   │   │   ├── interaction.ts
│   │   │   │   │   │   ├── javascript.ts
│   │   │   │   │   │   ├── keyboard.ts
│   │   │   │   │   │   ├── network-capture-debugger.ts
│   │   │   │   │   │   ├── network-capture-web-request.ts
│   │   │   │   │   │   ├── network-capture.ts
│   │   │   │   │   │   ├── network-request.ts
│   │   │   │   │   │   ├── performance.ts
│   │   │   │   │   │   ├── read-page.ts
│   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   ├── userscript.ts
│   │   │   │   │   │   ├── vector-search.ts
│   │   │   │   │   │   ├── web-fetcher.ts
│   │   │   │   │   │   └── window.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── record-replay.ts
│   │   │   │   ├── utils
│   │   │   │   │   └── sidepanel.ts
│   │   │   │   └── web-editor
│   │   │   │       └── index.ts
│   │   │   ├── builder
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── content.ts
│   │   │   ├── element-picker.content.ts
│   │   │   ├── offscreen
│   │   │   │   ├── gif-encoder.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── rr-keepalive.ts
│   │   │   ├── options
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── popup
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── builder
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── Canvas.vue
│   │   │   │   │   │   │   ├── EdgePropertyPanel.vue
│   │   │   │   │   │   │   ├── KeyValueEditor.vue
│   │   │   │   │   │   │   ├── nodes
│   │   │   │   │   │   │   │   ├── node-util.ts
│   │   │   │   │   │   │   │   ├── NodeCard.vue
│   │   │   │   │   │   │   │   └── NodeIf.vue
│   │   │   │   │   │   │   ├── properties
│   │   │   │   │   │   │   │   ├── PropertyAssert.vue
│   │   │   │   │   │   │   │   ├── PropertyClick.vue
│   │   │   │   │   │   │   │   ├── PropertyCloseTab.vue
│   │   │   │   │   │   │   │   ├── PropertyDelay.vue
│   │   │   │   │   │   │   │   ├── PropertyDrag.vue
│   │   │   │   │   │   │   │   ├── PropertyExecuteFlow.vue
│   │   │   │   │   │   │   │   ├── PropertyExtract.vue
│   │   │   │   │   │   │   │   ├── PropertyFill.vue
│   │   │   │   │   │   │   │   ├── PropertyForeach.vue
│   │   │   │   │   │   │   │   ├── PropertyFormRenderer.vue
│   │   │   │   │   │   │   │   ├── PropertyFromSpec.vue
│   │   │   │   │   │   │   │   ├── PropertyHandleDownload.vue
│   │   │   │   │   │   │   │   ├── PropertyHttp.vue
│   │   │   │   │   │   │   │   ├── PropertyIf.vue
│   │   │   │   │   │   │   │   ├── PropertyKey.vue
│   │   │   │   │   │   │   │   ├── PropertyLoopElements.vue
│   │   │   │   │   │   │   │   ├── PropertyNavigate.vue
│   │   │   │   │   │   │   │   ├── PropertyOpenTab.vue
│   │   │   │   │   │   │   │   ├── PropertyScreenshot.vue
│   │   │   │   │   │   │   │   ├── PropertyScript.vue
│   │   │   │   │   │   │   │   ├── PropertyScroll.vue
│   │   │   │   │   │   │   │   ├── PropertySetAttribute.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchFrame.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchTab.vue
│   │   │   │   │   │   │   │   ├── PropertyTrigger.vue
│   │   │   │   │   │   │   │   ├── PropertyTriggerEvent.vue
│   │   │   │   │   │   │   │   ├── PropertyWait.vue
│   │   │   │   │   │   │   │   ├── PropertyWhile.vue
│   │   │   │   │   │   │   │   └── SelectorEditor.vue
│   │   │   │   │   │   │   ├── PropertyPanel.vue
│   │   │   │   │   │   │   ├── Sidebar.vue
│   │   │   │   │   │   │   └── TriggerPanel.vue
│   │   │   │   │   │   ├── model
│   │   │   │   │   │   │   ├── form-widget-registry.ts
│   │   │   │   │   │   │   ├── node-spec-registry.ts
│   │   │   │   │   │   │   ├── node-spec.ts
│   │   │   │   │   │   │   ├── node-specs-builtin.ts
│   │   │   │   │   │   │   ├── toast.ts
│   │   │   │   │   │   │   ├── transforms.ts
│   │   │   │   │   │   │   ├── ui-nodes.ts
│   │   │   │   │   │   │   ├── validation.ts
│   │   │   │   │   │   │   └── variables.ts
│   │   │   │   │   │   ├── store
│   │   │   │   │   │   │   └── useBuilderStore.ts
│   │   │   │   │   │   └── widgets
│   │   │   │   │   │       ├── FieldCode.vue
│   │   │   │   │   │       ├── FieldDuration.vue
│   │   │   │   │   │       ├── FieldExpression.vue
│   │   │   │   │   │       ├── FieldKeySequence.vue
│   │   │   │   │   │       ├── FieldSelector.vue
│   │   │   │   │   │       ├── FieldTargetLocator.vue
│   │   │   │   │   │       └── VarInput.vue
│   │   │   │   │   ├── ConfirmDialog.vue
│   │   │   │   │   ├── ElementMarkerManagement.vue
│   │   │   │   │   ├── icons
│   │   │   │   │   │   ├── BoltIcon.vue
│   │   │   │   │   │   ├── CheckIcon.vue
│   │   │   │   │   │   ├── DatabaseIcon.vue
│   │   │   │   │   │   ├── DocumentIcon.vue
│   │   │   │   │   │   ├── EditIcon.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MarkerIcon.vue
│   │   │   │   │   │   ├── RecordIcon.vue
│   │   │   │   │   │   ├── RefreshIcon.vue
│   │   │   │   │   │   ├── StopIcon.vue
│   │   │   │   │   │   ├── TabIcon.vue
│   │   │   │   │   │   ├── TrashIcon.vue
│   │   │   │   │   │   ├── VectorIcon.vue
│   │   │   │   │   │   └── WorkflowIcon.vue
│   │   │   │   │   ├── LocalModelPage.vue
│   │   │   │   │   ├── ModelCacheManagement.vue
│   │   │   │   │   ├── ProgressIndicator.vue
│   │   │   │   │   └── ScheduleDialog.vue
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── style.css
│   │   │   ├── quick-panel.content.ts
│   │   │   ├── shared
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── useRRV3Rpc.ts
│   │   │   │   └── utils
│   │   │   │       ├── index.ts
│   │   │   │       └── rr-flow-convert.ts
│   │   │   ├── sidepanel
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── agent
│   │   │   │   │   │   ├── AttachmentPreview.vue
│   │   │   │   │   │   ├── ChatInput.vue
│   │   │   │   │   │   ├── CliSettings.vue
│   │   │   │   │   │   ├── ConnectionStatus.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MessageItem.vue
│   │   │   │   │   │   ├── MessageList.vue
│   │   │   │   │   │   ├── ProjectCreateForm.vue
│   │   │   │   │   │   └── ProjectSelector.vue
│   │   │   │   │   ├── agent-chat
│   │   │   │   │   │   ├── AgentChatShell.vue
│   │   │   │   │   │   ├── AgentComposer.vue
│   │   │   │   │   │   ├── AgentConversation.vue
│   │   │   │   │   │   ├── AgentOpenProjectMenu.vue
│   │   │   │   │   │   ├── AgentProjectMenu.vue
│   │   │   │   │   │   ├── AgentRequestThread.vue
│   │   │   │   │   │   ├── AgentSessionListItem.vue
│   │   │   │   │   │   ├── AgentSessionMenu.vue
│   │   │   │   │   │   ├── AgentSessionSettingsPanel.vue
│   │   │   │   │   │   ├── AgentSessionsView.vue
│   │   │   │   │   │   ├── AgentSettingsMenu.vue
│   │   │   │   │   │   ├── AgentTimeline.vue
│   │   │   │   │   │   ├── AgentTimelineItem.vue
│   │   │   │   │   │   ├── AgentTopBar.vue
│   │   │   │   │   │   ├── ApplyMessageChip.vue
│   │   │   │   │   │   ├── AttachmentCachePanel.vue
│   │   │   │   │   │   ├── ComposerDrawer.vue
│   │   │   │   │   │   ├── ElementChip.vue
│   │   │   │   │   │   ├── FakeCaretOverlay.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── SelectionChip.vue
│   │   │   │   │   │   ├── timeline
│   │   │   │   │   │   │   ├── markstream-thinking.ts
│   │   │   │   │   │   │   ├── ThinkingNode.vue
│   │   │   │   │   │   │   ├── TimelineNarrativeStep.vue
│   │   │   │   │   │   │   ├── TimelineStatusStep.vue
│   │   │   │   │   │   │   ├── TimelineToolCallStep.vue
│   │   │   │   │   │   │   ├── TimelineToolResultCardStep.vue
│   │   │   │   │   │   │   └── TimelineUserPromptStep.vue
│   │   │   │   │   │   └── WebEditorChanges.vue
│   │   │   │   │   ├── AgentChat.vue
│   │   │   │   │   ├── rr-v3
│   │   │   │   │   │   └── DebuggerPanel.vue
│   │   │   │   │   ├── SidepanelNavigator.vue
│   │   │   │   │   └── workflows
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── WorkflowListItem.vue
│   │   │   │   │       └── WorkflowsView.vue
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── useAgentChat.ts
│   │   │   │   │   ├── useAgentChatViewRoute.ts
│   │   │   │   │   ├── useAgentInputPreferences.ts
│   │   │   │   │   ├── useAgentProjects.ts
│   │   │   │   │   ├── useAgentServer.ts
│   │   │   │   │   ├── useAgentSessions.ts
│   │   │   │   │   ├── useAgentTheme.ts
│   │   │   │   │   ├── useAgentThreads.ts
│   │   │   │   │   ├── useAttachments.ts
│   │   │   │   │   ├── useFakeCaret.ts
│   │   │   │   │   ├── useFloatingDrag.ts
│   │   │   │   │   ├── useOpenProjectPreference.ts
│   │   │   │   │   ├── useRRV3Debugger.ts
│   │   │   │   │   ├── useRRV3Rpc.ts
│   │   │   │   │   ├── useTextareaAutoResize.ts
│   │   │   │   │   ├── useWebEditorTxState.ts
│   │   │   │   │   └── useWorkflowsV3.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   ├── styles
│   │   │   │   │   └── agent-chat.css
│   │   │   │   └── utils
│   │   │   │       └── loading-texts.ts
│   │   │   ├── styles
│   │   │   │   └── tailwind.css
│   │   │   ├── web-editor-v2
│   │   │   │   ├── attr-ui-refactor.md
│   │   │   │   ├── constants.ts
│   │   │   │   ├── core
│   │   │   │   │   ├── css-compare.ts
│   │   │   │   │   ├── cssom-styles-collector.ts
│   │   │   │   │   ├── debug-source.ts
│   │   │   │   │   ├── design-tokens
│   │   │   │   │   │   ├── design-tokens-service.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── token-detector.ts
│   │   │   │   │   │   ├── token-resolver.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── editor.ts
│   │   │   │   │   ├── element-key.ts
│   │   │   │   │   ├── event-controller.ts
│   │   │   │   │   ├── execution-tracker.ts
│   │   │   │   │   ├── hmr-consistency.ts
│   │   │   │   │   ├── locator.ts
│   │   │   │   │   ├── message-listener.ts
│   │   │   │   │   ├── payload-builder.ts
│   │   │   │   │   ├── perf-monitor.ts
│   │   │   │   │   ├── position-tracker.ts
│   │   │   │   │   ├── props-bridge.ts
│   │   │   │   │   ├── snap-engine.ts
│   │   │   │   │   ├── transaction-aggregator.ts
│   │   │   │   │   └── transaction-manager.ts
│   │   │   │   ├── drag
│   │   │   │   │   └── drag-reorder-controller.ts
│   │   │   │   ├── overlay
│   │   │   │   │   ├── canvas-overlay.ts
│   │   │   │   │   └── handles-controller.ts
│   │   │   │   ├── selection
│   │   │   │   │   └── selection-engine.ts
│   │   │   │   ├── ui
│   │   │   │   │   ├── breadcrumbs.ts
│   │   │   │   │   ├── floating-drag.ts
│   │   │   │   │   ├── icons.ts
│   │   │   │   │   ├── property-panel
│   │   │   │   │   │   ├── class-editor.ts
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── alignment-grid.ts
│   │   │   │   │   │   │   ├── icon-button-group.ts
│   │   │   │   │   │   │   ├── input-container.ts
│   │   │   │   │   │   │   ├── slider-input.ts
│   │   │   │   │   │   │   └── token-pill.ts
│   │   │   │   │   │   ├── components-tree.ts
│   │   │   │   │   │   ├── controls
│   │   │   │   │   │   │   ├── appearance-control.ts
│   │   │   │   │   │   │   ├── background-control.ts
│   │   │   │   │   │   │   ├── border-control.ts
│   │   │   │   │   │   │   ├── color-field.ts
│   │   │   │   │   │   │   ├── css-helpers.ts
│   │   │   │   │   │   │   ├── effects-control.ts
│   │   │   │   │   │   │   ├── gradient-control.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── layout-control.ts
│   │   │   │   │   │   │   ├── number-stepping.ts
│   │   │   │   │   │   │   ├── position-control.ts
│   │   │   │   │   │   │   ├── size-control.ts
│   │   │   │   │   │   │   ├── spacing-control.ts
│   │   │   │   │   │   │   ├── token-picker.ts
│   │   │   │   │   │   │   └── typography-control.ts
│   │   │   │   │   │   ├── css-defaults.ts
│   │   │   │   │   │   ├── css-panel.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── property-panel.ts
│   │   │   │   │   │   ├── props-panel.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── shadow-host.ts
│   │   │   │   │   └── toolbar.ts
│   │   │   │   └── utils
│   │   │   │       └── disposables.ts
│   │   │   ├── web-editor-v2.ts
│   │   │   └── welcome
│   │   │       ├── App.vue
│   │   │       ├── index.html
│   │   │       └── main.ts
│   │   ├── env.d.ts
│   │   ├── eslint.config.js
│   │   ├── inject-scripts
│   │   │   ├── accessibility-tree-helper.js
│   │   │   ├── click-helper.js
│   │   │   ├── dom-observer.js
│   │   │   ├── element-marker.js
│   │   │   ├── element-picker.js
│   │   │   ├── fill-helper.js
│   │   │   ├── inject-bridge.js
│   │   │   ├── interactive-elements-helper.js
│   │   │   ├── keyboard-helper.js
│   │   │   ├── network-helper.js
│   │   │   ├── props-agent.js
│   │   │   ├── recorder.js
│   │   │   ├── screenshot-helper.js
│   │   │   ├── wait-helper.js
│   │   │   ├── web-editor.js
│   │   │   └── web-fetcher-helper.js
│   │   ├── LICENSE
│   │   ├── package.json
│   │   ├── public
│   │   │   ├── icon
│   │   │   │   ├── 128.png
│   │   │   │   ├── 16.png
│   │   │   │   ├── 32.png
│   │   │   │   ├── 48.png
│   │   │   │   └── 96.png
│   │   │   ├── libs
│   │   │   │   └── ort.min.js
│   │   │   └── wxt.svg
│   │   ├── README.md
│   │   ├── shared
│   │   │   ├── element-picker
│   │   │   │   ├── controller.ts
│   │   │   │   └── index.ts
│   │   │   ├── quick-panel
│   │   │   │   ├── core
│   │   │   │   │   ├── agent-bridge.ts
│   │   │   │   │   ├── search-engine.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── providers
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── tabs-provider.ts
│   │   │   │   └── ui
│   │   │   │       ├── ai-chat-panel.ts
│   │   │   │       ├── index.ts
│   │   │   │       ├── markdown-renderer.ts
│   │   │   │       ├── message-renderer.ts
│   │   │   │       ├── panel-shell.ts
│   │   │   │       ├── quick-entries.ts
│   │   │   │       ├── search-input.ts
│   │   │   │       ├── shadow-host.ts
│   │   │   │       └── styles.ts
│   │   │   └── selector
│   │   │       ├── dom-path.ts
│   │   │       ├── fingerprint.ts
│   │   │       ├── generator.ts
│   │   │       ├── index.ts
│   │   │       ├── locator.ts
│   │   │       ├── shadow-dom.ts
│   │   │       ├── stability.ts
│   │   │       ├── strategies
│   │   │       │   ├── anchor-relpath.ts
│   │   │       │   ├── aria.ts
│   │   │       │   ├── css-path.ts
│   │   │       │   ├── css-unique.ts
│   │   │       │   ├── index.ts
│   │   │       │   ├── testid.ts
│   │   │       │   └── text.ts
│   │   │       └── types.ts
│   │   ├── tailwind.config.ts
│   │   ├── tests
│   │   │   ├── __mocks__
│   │   │   │   └── hnswlib-wasm-static.ts
│   │   │   ├── record-replay
│   │   │   │   ├── _test-helpers.ts
│   │   │   │   ├── adapter-policy.contract.test.ts
│   │   │   │   ├── flow-store-strip-steps.contract.test.ts
│   │   │   │   ├── high-risk-actions.integration.test.ts
│   │   │   │   ├── hybrid-actions.integration.test.ts
│   │   │   │   ├── script-control-flow.integration.test.ts
│   │   │   │   ├── session-dag-sync.contract.test.ts
│   │   │   │   ├── step-executor.contract.test.ts
│   │   │   │   └── tab-cursor.integration.test.ts
│   │   │   ├── record-replay-v3
│   │   │   │   ├── command-trigger.test.ts
│   │   │   │   ├── context-menu-trigger.test.ts
│   │   │   │   ├── cron-trigger.test.ts
│   │   │   │   ├── debugger.contract.test.ts
│   │   │   │   ├── dom-trigger.test.ts
│   │   │   │   ├── e2e.integration.test.ts
│   │   │   │   ├── events.contract.test.ts
│   │   │   │   ├── interval-trigger.test.ts
│   │   │   │   ├── manual-trigger.test.ts
│   │   │   │   ├── once-trigger.test.ts
│   │   │   │   ├── queue.contract.test.ts
│   │   │   │   ├── recovery.test.ts
│   │   │   │   ├── rpc-api.test.ts
│   │   │   │   ├── runner.onError.contract.test.ts
│   │   │   │   ├── scheduler-integration.test.ts
│   │   │   │   ├── scheduler.test.ts
│   │   │   │   ├── spec-smoke.test.ts
│   │   │   │   ├── trigger-manager.test.ts
│   │   │   │   ├── triggers.test.ts
│   │   │   │   ├── url-trigger.test.ts
│   │   │   │   ├── v2-action-adapter.test.ts
│   │   │   │   ├── v2-adapter-integration.test.ts
│   │   │   │   ├── v2-to-v3-conversion.test.ts
│   │   │   │   └── v3-e2e-harness.ts
│   │   │   ├── vitest.setup.ts
│   │   │   └── web-editor-v2
│   │   │       ├── design-tokens.test.ts
│   │   │       ├── drag-reorder-controller.test.ts
│   │   │       ├── event-controller.test.ts
│   │   │       ├── locator.test.ts
│   │   │       ├── property-panel-live-sync.test.ts
│   │   │       ├── selection-engine.test.ts
│   │   │       ├── snap-engine.test.ts
│   │   │       └── test-utils
│   │   │           └── dom.ts
│   │   ├── tsconfig.json
│   │   ├── types
│   │   │   ├── gifenc.d.ts
│   │   │   └── icons.d.ts
│   │   ├── utils
│   │   │   ├── cdp-session-manager.ts
│   │   │   ├── content-indexer.ts
│   │   │   ├── i18n.ts
│   │   │   ├── image-utils.ts
│   │   │   ├── indexeddb-client.ts
│   │   │   ├── lru-cache.ts
│   │   │   ├── model-cache-manager.ts
│   │   │   ├── offscreen-manager.ts
│   │   │   ├── output-sanitizer.ts
│   │   │   ├── screenshot-context.ts
│   │   │   ├── semantic-similarity-engine.ts
│   │   │   ├── simd-math-engine.ts
│   │   │   ├── text-chunker.ts
│   │   │   └── vector-database.ts
│   │   ├── vitest.config.ts
│   │   ├── workers
│   │   │   ├── ort-wasm-simd-threaded.jsep.mjs
│   │   │   ├── ort-wasm-simd-threaded.jsep.wasm
│   │   │   ├── ort-wasm-simd-threaded.mjs
│   │   │   ├── ort-wasm-simd-threaded.wasm
│   │   │   ├── simd_math_bg.wasm
│   │   │   ├── simd_math.js
│   │   │   └── similarity.worker.js
│   │   └── wxt.config.ts
│   └── native-server
│       ├── .npmignore
│       ├── debug.sh
│       ├── install.md
│       ├── jest.config.js
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   ├── agent
│       │   │   ├── attachment-service.ts
│       │   │   ├── ccr-detector.ts
│       │   │   ├── chat-service.ts
│       │   │   ├── db
│       │   │   │   ├── client.ts
│       │   │   │   ├── index.ts
│       │   │   │   └── schema.ts
│       │   │   ├── directory-picker.ts
│       │   │   ├── engines
│       │   │   │   ├── claude.ts
│       │   │   │   ├── codex.ts
│       │   │   │   └── types.ts
│       │   │   ├── message-service.ts
│       │   │   ├── open-project.ts
│       │   │   ├── project-service.ts
│       │   │   ├── project-types.ts
│       │   │   ├── session-service.ts
│       │   │   ├── storage.ts
│       │   │   ├── stream-manager.ts
│       │   │   ├── tool-bridge.ts
│       │   │   └── types.ts
│       │   ├── cli.ts
│       │   ├── constant
│       │   │   └── index.ts
│       │   ├── file-handler.ts
│       │   ├── index.ts
│       │   ├── mcp
│       │   │   ├── mcp-server-stdio.ts
│       │   │   ├── mcp-server.ts
│       │   │   ├── register-tools.ts
│       │   │   └── stdio-config.json
│       │   ├── native-messaging-host.ts
│       │   ├── scripts
│       │   │   ├── browser-config.ts
│       │   │   ├── build.ts
│       │   │   ├── constant.ts
│       │   │   ├── doctor.ts
│       │   │   ├── postinstall.ts
│       │   │   ├── register-dev.ts
│       │   │   ├── register.ts
│       │   │   ├── report.ts
│       │   │   ├── run_host.bat
│       │   │   ├── run_host.sh
│       │   │   └── utils.ts
│       │   ├── server
│       │   │   ├── index.ts
│       │   │   ├── routes
│       │   │   │   ├── agent.ts
│       │   │   │   └── index.ts
│       │   │   └── server.test.ts
│       │   ├── shims
│       │   │   └── devtools.d.ts
│       │   ├── trace-analyzer.ts
│       │   ├── types
│       │   │   └── devtools-frontend.d.ts
│       │   └── util
│       │       └── logger.ts
│       └── tsconfig.json
├── commitlint.config.cjs
├── docs
│   ├── ARCHITECTURE_zh.md
│   ├── ARCHITECTURE.md
│   ├── CHANGELOG.md
│   ├── CONTRIBUTING_zh.md
│   ├── CONTRIBUTING.md
│   ├── ISSUE.md
│   ├── mcp-cli-config.md
│   ├── TOOLS_zh.md
│   ├── TOOLS.md
│   ├── TROUBLESHOOTING_zh.md
│   ├── TROUBLESHOOTING.md
│   ├── VisualEditor_zh.md
│   ├── VisualEditor.md
│   └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│   ├── shared
│   │   ├── package.json
│   │   ├── src
│   │   │   ├── agent-types.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── labels.ts
│   │   │   ├── node-spec-registry.ts
│   │   │   ├── node-spec.ts
│   │   │   ├── node-specs-builtin.ts
│   │   │   ├── rr-graph.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tools.ts
│   │   │   └── types.ts
│   │   └── tsconfig.json
│   └── wasm-simd
│       ├── .gitignore
│       ├── BUILD.md
│       ├── Cargo.toml
│       ├── package.json
│       ├── README.md
│       └── src
│           └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│   ├── content-analize.md
│   ├── excalidraw-prompt.md
│   └── modify-web.md
├── README_zh.md
├── README.md
└── releases
    ├── chrome-extension
    │   └── latest
    │       └── chrome-mcp-server-lastest.zip
    └── README.md
```

# Files

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * @fileoverview V3 IndexedDB 数据库定义
 * @description 定义 rr_v3 数据库的 schema 和初始化逻辑
 */

/** 数据库名称 */
export const RR_V3_DB_NAME = 'rr_v3';

/** 数据库版本 */
export const RR_V3_DB_VERSION = 1;

/**
 * Store 名称常量
 */
export const RR_V3_STORES = {
  FLOWS: 'flows',
  RUNS: 'runs',
  EVENTS: 'events',
  QUEUE: 'queue',
  PERSISTENT_VARS: 'persistent_vars',
  TRIGGERS: 'triggers',
} as const;

/**
 * Store 配置
 */
export interface StoreConfig {
  keyPath: string | string[];
  autoIncrement?: boolean;
  indexes?: Array<{
    name: string;
    keyPath: string | string[];
    options?: IDBIndexParameters;
  }>;
}

/**
 * V3 Store Schema 定义
 * @description 包含 Phase 1-3 所需的所有索引,避免后续升级
 */
export const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {
  [RR_V3_STORES.FLOWS]: {
    keyPath: 'id',
    indexes: [
      { name: 'name', keyPath: 'name' },
      { name: 'updatedAt', keyPath: 'updatedAt' },
    ],
  },
  [RR_V3_STORES.RUNS]: {
    keyPath: 'id',
    indexes: [
      { name: 'status', keyPath: 'status' },
      { name: 'flowId', keyPath: 'flowId' },
      { name: 'createdAt', keyPath: 'createdAt' },
      { name: 'updatedAt', keyPath: 'updatedAt' },
      // Compound index for listing runs by flow and status
      { name: 'flowId_status', keyPath: ['flowId', 'status'] },
    ],
  },
  [RR_V3_STORES.EVENTS]: {
    keyPath: ['runId', 'seq'],
    indexes: [
      { name: 'runId', keyPath: 'runId' },
      { name: 'type', keyPath: 'type' },
      // Compound index for filtering events by run and type
      { name: 'runId_type', keyPath: ['runId', 'type'] },
    ],
  },
  [RR_V3_STORES.QUEUE]: {
    keyPath: 'id',
    indexes: [
      { name: 'status', keyPath: 'status' },
      { name: 'priority', keyPath: 'priority' },
      { name: 'createdAt', keyPath: 'createdAt' },
      { name: 'flowId', keyPath: 'flowId' },
      // Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.
      { name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },
      // Phase 3: Lease expiration tracking
      { name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },
    ],
  },
  [RR_V3_STORES.PERSISTENT_VARS]: {
    keyPath: 'key',
    indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],
  },
  [RR_V3_STORES.TRIGGERS]: {
    keyPath: 'id',
    indexes: [
      { name: 'kind', keyPath: 'kind' },
      { name: 'flowId', keyPath: 'flowId' },
      { name: 'enabled', keyPath: 'enabled' },
      // Compound index for listing enabled triggers by kind
      { name: 'kind_enabled', keyPath: ['kind', 'enabled'] },
    ],
  },
};

/**
 * 数据库升级处理器
 */
export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {
  // Version 0 -> 1: 创建所有 stores
  if (oldVersion < 1) {
    for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {
      const store = db.createObjectStore(storeName, {
        keyPath: config.keyPath,
        autoIncrement: config.autoIncrement,
      });

      // 创建索引
      if (config.indexes) {
        for (const index of config.indexes) {
          store.createIndex(index.name, index.keyPath, index.options);
        }
      }
    }
  }
}

/** 全局数据库实例 */
let dbInstance: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null;

/**
 * 打开 V3 数据库
 * @description 单例模式,确保只有一个数据库连接
 */
export async function openRrV3Db(): Promise<IDBDatabase> {
  if (dbInstance) {
    return dbInstance;
  }

  if (dbPromise) {
    return dbPromise;
  }

  dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);

    request.onerror = () => {
      dbPromise = null;
      reject(new Error(`Failed to open database: ${request.error?.message}`));
    };

    request.onsuccess = () => {
      dbInstance = request.result;

      // 处理版本变更(其他 tab 升级了数据库)
      dbInstance.onversionchange = () => {
        dbInstance?.close();
        dbInstance = null;
        dbPromise = null;
      };

      resolve(dbInstance);
    };

    request.onupgradeneeded = (event) => {
      const db = request.result;
      const oldVersion = event.oldVersion;
      const newVersion = event.newVersion ?? RR_V3_DB_VERSION;
      handleUpgrade(db, oldVersion, newVersion);
    };
  });

  return dbPromise;
}

/**
 * 关闭数据库连接
 * @description 主要用于测试
 */
export function closeRrV3Db(): void {
  if (dbInstance) {
    dbInstance.close();
    dbInstance = null;
    dbPromise = null;
  }
}

/**
 * 删除数据库
 * @description 主要用于测试
 */
export async function deleteRrV3Db(): Promise<void> {
  closeRrV3Db();

  return new Promise((resolve, reject) => {
    const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

/**
 * 执行事务
 * @param storeNames Store 名称(单个或多个)
 * @param mode 事务模式
 * @param callback 事务回调
 */
export async function withTransaction<T>(
  storeNames: string | string[],
  mode: IDBTransactionMode,
  callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,
): Promise<T> {
  const db = await openRrV3Db();
  const names = Array.isArray(storeNames) ? storeNames : [storeNames];
  const tx = db.transaction(names, mode);

  const stores: Record<string, IDBObjectStore> = {};
  for (const name of names) {
    stores[name] = tx.objectStore(name);
  }

  return new Promise<T>((resolve, reject) => {
    let result: T;

    tx.oncomplete = () => resolve(result);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));

    Promise.resolve(callback(stores))
      .then((r) => {
        result = r;
      })
      .catch((err) => {
        tx.abort();
        reject(err);
      });
  });
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/stability.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Selector Stability - 选择器稳定性评估
 */

import type {
  SelectorCandidate,
  SelectorStability,
  SelectorStabilitySignals,
  SelectorType,
} from './types';
import { splitCompositeSelector } from './types';

const TESTID_ATTR_NAMES = [
  'data-testid',
  'data-test-id',
  'data-test',
  'data-qa',
  'data-cy',
] as const;

function clamp01(n: number): number {
  if (!Number.isFinite(n)) return 0;
  return Math.min(1, Math.max(0, n));
}

function mergeSignals(
  a: SelectorStabilitySignals,
  b: SelectorStabilitySignals,
): SelectorStabilitySignals {
  return {
    usesId: a.usesId || b.usesId || undefined,
    usesTestId: a.usesTestId || b.usesTestId || undefined,
    usesAria: a.usesAria || b.usesAria || undefined,
    usesText: a.usesText || b.usesText || undefined,
    usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,
    usesAttributes: a.usesAttributes || b.usesAttributes || undefined,
    usesClass: a.usesClass || b.usesClass || undefined,
  };
}

function analyzeCssLike(selector: string): SelectorStabilitySignals {
  const s = String(selector || '');
  const usesNthOfType = /:nth-of-type\(/i.test(s);
  const usesAttributes = /\[[^\]]+\]/.test(s);
  const usesAria = /\[\s*aria-[^=]+\s*=|\[\s*role\s*=|\brole\s*=\s*/i.test(s);

  // Avoid counting `#` inside attribute values (e.g. href="#...") by requiring a token-ish pattern.
  const usesId = /(^|[\s>+~])#[^\s>+~.:#[]+/.test(s);
  const usesClass = /(^|[\s>+~])\.[^\s>+~.:#[]+/.test(s);

  const lower = s.toLowerCase();
  const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));

  return {
    usesId: usesId || undefined,
    usesTestId: usesTestId || undefined,
    usesAria: usesAria || undefined,
    usesNthOfType: usesNthOfType || undefined,
    usesAttributes: usesAttributes || undefined,
    usesClass: usesClass || undefined,
  };
}

function baseScoreForCssSignals(signals: SelectorStabilitySignals): number {
  if (signals.usesTestId) return 0.95;
  if (signals.usesId) return 0.9;
  if (signals.usesAria) return 0.8;
  if (signals.usesAttributes) return 0.75;
  if (signals.usesClass) return 0.65;
  return 0.5;
}

function lengthPenalty(value: string): number {
  const len = value.length;
  if (len <= 60) return 0;
  if (len <= 120) return 0.05;
  if (len <= 200) return 0.1;
  return 0.18;
}

/**
 * 计算选择器稳定性评分
 */
export function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {
  if (candidate.type === 'css' || candidate.type === 'attr') {
    const composite = splitCompositeSelector(candidate.value);
    if (composite) {
      const a = analyzeCssLike(composite.frameSelector);
      const b = analyzeCssLike(composite.innerSelector);
      const merged = mergeSignals(a, b);

      let score = baseScoreForCssSignals(merged);
      score -= 0.05; // iframe coupling penalty
      if (merged.usesNthOfType) score -= 0.2;
      score -= lengthPenalty(candidate.value);

      return { score: clamp01(score), signals: merged, note: 'composite' };
    }

    const signals = analyzeCssLike(candidate.value);
    let score = baseScoreForCssSignals(signals);
    if (signals.usesNthOfType) score -= 0.2;
    score -= lengthPenalty(candidate.value);

    return { score: clamp01(score), signals };
  }

  if (candidate.type === 'xpath') {
    const s = String(candidate.value || '');
    const signals: SelectorStabilitySignals = {
      usesAttributes: /@[\w-]+\s*=/.test(s) || undefined,
      usesId: /@id\s*=/.test(s) || undefined,
      usesTestId: /@data-testid\s*=/.test(s) || undefined,
    };

    let score = 0.42;
    if (signals.usesTestId) score = 0.85;
    else if (signals.usesId) score = 0.75;
    else if (signals.usesAttributes) score = 0.55;

    score -= lengthPenalty(s);
    return { score: clamp01(score), signals };
  }

  if (candidate.type === 'aria') {
    const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
    const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;

    const signals: SelectorStabilitySignals = { usesAria: true };
    let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;
    score -= lengthPenalty(candidate.value);

    return { score: clamp01(score), signals };
  }

  // text
  const text = String(candidate.value || '').trim();
  const signals: SelectorStabilitySignals = { usesText: true };
  let score = 0.35;

  // Very short texts tend to be ambiguous; very long texts are unstable.
  if (text.length >= 6 && text.length <= 48) score = 0.45;
  if (text.length > 80) score = 0.3;

  return { score: clamp01(score), signals };
}

/**
 * 为选择器候选添加稳定性评分
 */
export function withStability(candidate: SelectorCandidate): SelectorCandidate {
  if (candidate.stability) return candidate;
  return { ...candidate, stability: computeSelectorStability(candidate) };
}

function typePriority(type: SelectorType): number {
  switch (type) {
    case 'attr':
      return 5;
    case 'css':
      return 4;
    case 'aria':
      return 3;
    case 'xpath':
      return 2;
    case 'text':
      return 1;
    default:
      return 0;
  }
}

/**
 * 比较两个选择器候选的优先级
 * 返回负数表示 a 优先,正数表示 b 优先
 */
export function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {
  // 1. 用户指定的权重优先
  const aw = a.weight ?? 0;
  const bw = b.weight ?? 0;
  if (aw !== bw) return bw - aw;

  // 2. 稳定性评分
  const as = a.stability?.score ?? computeSelectorStability(a).score;
  const bs = b.stability?.score ?? computeSelectorStability(b).score;
  if (as !== bs) return bs - as;

  // 3. 类型优先级
  const ap = typePriority(a.type);
  const bp = typePriority(b.type);
  if (ap !== bp) return bp - ap;

  // 4. 长度(越短越好)
  const alen = String(a.value || '').length;
  const blen = String(b.value || '').length;
  return alen - blen;
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/common/constants.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Chrome Extension Constants
 * Centralized configuration values and magic constants
 */

// Native Host Configuration
export const NATIVE_HOST = {
  NAME: 'com.chromemcp.nativehost',
  DEFAULT_PORT: 12306,
} as const;

// Chrome Extension Icons
export const ICONS = {
  NOTIFICATION: 'icon/48.png',
} as const;

// Timeouts and Delays (in milliseconds)
export const TIMEOUTS = {
  DEFAULT_WAIT: 1000,
  NETWORK_CAPTURE_MAX: 30000,
  NETWORK_CAPTURE_IDLE: 3000,
  SCREENSHOT_DELAY: 100,
  KEYBOARD_DELAY: 50,
  CLICK_DELAY: 100,
} as const;

// Limits and Thresholds
export const LIMITS = {
  MAX_NETWORK_REQUESTS: 100,
  MAX_SEARCH_RESULTS: 50,
  MAX_BOOKMARK_RESULTS: 100,
  MAX_HISTORY_RESULTS: 100,
  SIMILARITY_THRESHOLD: 0.1,
  VECTOR_DIMENSIONS: 384,
} as const;

// Error Messages
export const ERROR_MESSAGES = {
  NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
  NATIVE_DISCONNECTED: 'Native connection disconnected',
  SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
  SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
  TOOL_EXECUTION_FAILED: 'Tool execution failed',
  INVALID_PARAMETERS: 'Invalid parameters provided',
  PERMISSION_DENIED: 'Permission denied',
  TAB_NOT_FOUND: 'Tab not found',
  ELEMENT_NOT_FOUND: 'Element not found',
  NETWORK_ERROR: 'Network error occurred',
} as const;

// Success Messages
export const SUCCESS_MESSAGES = {
  TOOL_EXECUTED: 'Tool executed successfully',
  CONNECTION_ESTABLISHED: 'Connection established',
  SERVER_STARTED: 'Server started successfully',
  SERVER_STOPPED: 'Server stopped successfully',
} as const;

// External Links
export const LINKS = {
  TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
} as const;

// File Extensions and MIME Types
export const FILE_TYPES = {
  STATIC_EXTENSIONS: [
    '.css',
    '.js',
    '.png',
    '.jpg',
    '.jpeg',
    '.gif',
    '.svg',
    '.ico',
    '.woff',
    '.woff2',
    '.ttf',
  ],
  FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
  IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
} as const;

// Network Filtering
export const NETWORK_FILTERS = {
  // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
  EXCLUDED_DOMAINS: [
    // Google
    'google-analytics.com',
    'googletagmanager.com',
    'analytics.google.com',
    'doubleclick.net',
    'googlesyndication.com',
    'googleads.g.doubleclick.net',
    'stats.g.doubleclick.net',
    'adservice.google.com',
    'pagead2.googlesyndication.com',
    // Amazon
    'amazon-adsystem.com',
    // Microsoft
    'bat.bing.com',
    'clarity.ms',
    // Facebook
    'connect.facebook.net',
    'facebook.com/tr',
    // Twitter
    'analytics.twitter.com',
    'ads-twitter.com',
    // Other ad networks
    'ads.yahoo.com',
    'adroll.com',
    'adnxs.com',
    'criteo.com',
    'quantserve.com',
    'scorecardresearch.com',
    // Analytics & session recording
    'segment.io',
    'amplitude.com',
    'mixpanel.com',
    'optimizely.com',
    'static.hotjar.com',
    'script.hotjar.com',
    'crazyegg.com',
    'clicktale.net',
    'mouseflow.com',
    'fullstory.com',
    // LinkedIn (tracking pixels)
    'linkedin.com/px',
  ],
  // Static resource extensions (used when includeStatic=false)
  STATIC_RESOURCE_EXTENSIONS: [
    '.jpg',
    '.jpeg',
    '.png',
    '.gif',
    '.svg',
    '.webp',
    '.ico',
    '.bmp',
    '.cur',
    '.css',
    '.scss',
    '.less',
    '.js',
    '.jsx',
    '.ts',
    '.tsx',
    '.map',
    '.woff',
    '.woff2',
    '.ttf',
    '.eot',
    '.otf',
    '.mp3',
    '.mp4',
    '.avi',
    '.mov',
    '.wmv',
    '.flv',
    '.webm',
    '.ogg',
    '.wav',
    '.pdf',
    '.zip',
    '.rar',
    '.7z',
    '.iso',
    '.dmg',
    '.doc',
    '.docx',
    '.xls',
    '.xlsx',
    '.ppt',
    '.pptx',
  ],
  // MIME types treated as static/binary (filtered when includeStatic=false)
  STATIC_MIME_TYPES_TO_FILTER: [
    'image/',
    'font/',
    'audio/',
    'video/',
    'text/css',
    'text/javascript',
    'application/javascript',
    'application/x-javascript',
    'application/pdf',
    'application/zip',
    'application/octet-stream',
  ],
  // API-like MIME types (never filtered by MIME)
  API_MIME_TYPES: [
    'application/json',
    'application/xml',
    'text/xml',
    'text/plain',
    'text/event-stream',
    'application/x-www-form-urlencoded',
    'application/graphql',
    'application/grpc',
    'application/protobuf',
    'application/x-protobuf',
    'application/x-json',
    'application/ld+json',
    'application/problem+json',
    'application/problem+xml',
    'application/soap+xml',
    'application/vnd.api+json',
  ],
  STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
} as const;

// Semantic Similarity Configuration
export const SEMANTIC_CONFIG = {
  DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
  CHUNK_SIZE: 512,
  CHUNK_OVERLAP: 50,
  BATCH_SIZE: 32,
  CACHE_SIZE: 1000,
} as const;

// Storage Keys
export const STORAGE_KEYS = {
  SERVER_STATUS: 'serverStatus',
  NATIVE_SERVER_PORT: 'nativeServerPort',
  NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
  SEMANTIC_MODEL: 'selectedModel',
  USER_PREFERENCES: 'userPreferences',
  VECTOR_INDEX: 'vectorIndex',
  USERSCRIPTS: 'userscripts',
  USERSCRIPTS_DISABLED: 'userscripts_disabled',
  // Record & Replay storage keys
  RR_FLOWS: 'rr_flows',
  RR_RUNS: 'rr_runs',
  RR_PUBLISHED: 'rr_published_flows',
  RR_SCHEDULES: 'rr_schedules',
  RR_TRIGGERS: 'rr_triggers',
  // Persistent recording state (guards resume across navigations/service worker restarts)
  RR_RECORDING_STATE: 'rr_recording_state',
} as const;

// Notification Configuration
export const NOTIFICATIONS = {
  PRIORITY: 2,
  TYPE: 'basic' as const,
} as const;

export enum ExecutionWorld {
  ISOLATED = 'ISOLATED',
  MAIN = 'MAIN',
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue:
--------------------------------------------------------------------------------

```vue
<template>
  <div class="section">
    <h2 class="section-title">元素标注管理</h2>
    <div class="config-card">
      <div class="status-section" style="gap: 8px">
        <div class="status-header">
          <p class="status-label">当前页面</p>
          <span class="status-text" style="opacity: 0.85">{{ currentUrl }}</span>
        </div>
        <div class="status-header">
          <p class="status-label">已标注元素</p>
          <span class="status-text">{{ markers.length }}</span>
        </div>
      </div>

      <form class="mcp-config-section" @submit.prevent="onAdd">
        <div class="mcp-config-header">
          <p class="mcp-config-label">新增标注</p>
        </div>
        <div style="display: flex; gap: 8px; margin-bottom: 8px">
          <input v-model="form.name" placeholder="名称,如 登录按钮" class="port-input" />
          <select v-model="form.selectorType" class="port-input" style="max-width: 120px">
            <option value="css">CSS</option>
            <option value="xpath">XPath</option>
          </select>
          <select v-model="form.matchType" class="port-input" style="max-width: 120px">
            <option value="prefix">路径前缀</option>
            <option value="exact">精确匹配</option>
            <option value="host">域名</option>
          </select>
        </div>
        <input v-model="form.selector" placeholder="CSS 选择器" class="port-input" />
        <div style="display: flex; gap: 8px; margin-top: 8px">
          <button class="semantic-engine-button" :disabled="!form.selector" type="submit">
            保存
          </button>
          <button class="danger-button" type="button" @click="resetForm">清空</button>
        </div>
      </form>

      <div v-if="markers.length" class="model-list" style="margin-top: 8px">
        <div
          v-for="m in markers"
          :key="m.id"
          class="model-card"
          style="display: flex; align-items: center; justify-content: space-between; gap: 8px"
        >
          <div style="display: flex; flex-direction: column; gap: 4px">
            <strong class="model-name">{{ m.name }}</strong>
            <code style="font-size: 12px; opacity: 0.85">{{ m.selector }}</code>
            <div style="display: flex; gap: 6px; margin-top: 2px">
              <span class="model-tag dimension">{{ m.selectorType || 'css' }}</span>
              <span class="model-tag dimension">{{ m.matchType }}</span>
            </div>
          </div>
          <div style="display: flex; gap: 6px">
            <button class="semantic-engine-button" @click="validate(m)">验证</button>
            <button class="secondary-button" @click="prefill(m)">编辑</button>
            <button class="danger-button" @click="remove(m)">删除</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';

const currentUrl = ref('');
const markers = ref<ElementMarker[]>([]);

const form = ref<UpsertMarkerRequest>({
  url: '',
  name: '',
  selector: '',
  matchType: 'prefix',
});

function resetForm() {
  form.value = { url: currentUrl.value, name: '', selector: '', matchType: 'prefix' };
}

async function load() {
  try {
    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
    const t = tabs[0];
    currentUrl.value = String(t?.url || '');
    form.value.url = currentUrl.value;
    const res: any = await chrome.runtime.sendMessage({
      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL,
      url: currentUrl.value,
    });
    if (res?.success) markers.value = res.markers || [];
  } catch (e) {
    /* ignore */
  }
}

function prefill(m: ElementMarker) {
  form.value = {
    url: m.url,
    name: m.name,
    selector: m.selector,
    selectorType: m.selectorType,
    listMode: m.listMode,
    matchType: m.matchType,
    action: m.action,
    id: m.id,
  };
}

async function onAdd() {
  try {
    if (!form.value.selector) return;
    form.value.url = currentUrl.value;
    const res: any = await chrome.runtime.sendMessage({
      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
      marker: form.value,
    });
    if (res?.success) {
      resetForm();
      await load();
    }
  } catch (e) {
    /* ignore */
  }
}

async function remove(m: ElementMarker) {
  try {
    const res: any = await chrome.runtime.sendMessage({
      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,
      id: m.id,
    });
    if (res?.success) await load();
  } catch (e) {
    /* ignore */
  }
}

async function validate(m: ElementMarker) {
  try {
    const res: any = await chrome.runtime.sendMessage({
      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,
      selector: m.selector,
      selectorType: m.selectorType || 'css',
      action: 'hover',
      listMode: !!m.listMode,
    } as any);

    // Trigger highlight in the page only if tool validation succeeded
    if (res?.tool?.ok !== false) {
      await highlightInTab(m);
    }
  } catch (e) {
    /* ignore */
  }
}

async function highlightInTab(m: ElementMarker) {
  try {
    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
    const tabId = tabs[0]?.id;
    if (!tabId) return;

    // Ensure element-marker.js is injected
    try {
      await chrome.scripting.executeScript({
        target: { tabId, allFrames: true },
        files: ['inject-scripts/element-marker.js'],
        world: 'ISOLATED',
      });
    } catch {
      // Already injected, ignore
    }

    // Send highlight message to content script
    await chrome.tabs.sendMessage(tabId, {
      action: 'element_marker_highlight',
      selector: m.selector,
      selectorType: m.selectorType || 'css',
      listMode: !!m.listMode,
    });
  } catch (e) {
    // Ignore errors (tab might not support content scripts)
  }
}

onMounted(load);
</script>

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Input Container Component
 *
 * A reusable wrapper for inputs aligned with the attr-ui.html design spec.
 * Provides container-level hover/focus-within styling with optional prefix/suffix.
 *
 * Design spec pattern:
 * ```html
 * <div class="input-bg rounded h-[28px] flex items-center px-2">
 *   <span class="text-gray-400 mr-2">X</span>  <!-- prefix -->
 *   <input type="text" class="bg-transparent w-full outline-none">
 *   <span class="text-gray-400 text-[10px]">px</span>  <!-- suffix -->
 * </div>
 * ```
 *
 * CSS classes (defined in shadow-host.ts):
 * - `.we-input-container` - wrapper with hover/focus-within styles
 * - `.we-input-container__input` - transparent input
 * - `.we-input-container__prefix` - prefix element
 * - `.we-input-container__suffix` - suffix element (typically unit)
 */

// =============================================================================
// Types
// =============================================================================

/** Content for prefix/suffix: text string or DOM node (e.g., SVG icon) */
export type InputAffix = string | Node;

export interface InputContainerOptions {
  /** Accessible label for the input element */
  ariaLabel: string;
  /** Input type (default: "text") */
  type?: string;
  /** Input mode for virtual keyboard (e.g., "decimal", "numeric") */
  inputMode?: string;
  /** Optional prefix content (text label or icon) */
  prefix?: InputAffix | null;
  /** Optional suffix content (unit text or icon) */
  suffix?: InputAffix | null;
  /** Additional class name(s) for root container */
  rootClassName?: string;
  /** Additional class name(s) for input element */
  inputClassName?: string;
  /** Input autocomplete attribute (default: "off") */
  autocomplete?: string;
  /** Input spellcheck attribute (default: false) */
  spellcheck?: boolean;
  /** Initial placeholder text */
  placeholder?: string;
}

export interface InputContainer {
  /** Root container element */
  root: HTMLDivElement;
  /** Input element for wiring events */
  input: HTMLInputElement;
  /** Update prefix content */
  setPrefix(content: InputAffix | null): void;
  /** Update suffix content */
  setSuffix(content: InputAffix | null): void;
  /** Get current suffix text (null if no suffix or if suffix is a Node) */
  getSuffixText(): string | null;
}

// =============================================================================
// Helpers
// =============================================================================

function isNonEmptyString(value: unknown): value is string {
  return typeof value === 'string' && value.trim().length > 0;
}

function hasAffix(value: InputAffix | null | undefined): value is InputAffix {
  if (value === null || value === undefined) return false;
  return typeof value === 'string' ? value.trim().length > 0 : true;
}

function joinClassNames(...parts: Array<string | null | undefined | false>): string {
  return parts.filter(isNonEmptyString).join(' ');
}

// =============================================================================
// Factory
// =============================================================================

/**
 * Create an input container with optional prefix/suffix
 */
export function createInputContainer(options: InputContainerOptions): InputContainer {
  const {
    ariaLabel,
    type = 'text',
    inputMode,
    prefix,
    suffix,
    rootClassName,
    inputClassName,
    autocomplete = 'off',
    spellcheck = false,
    placeholder,
  } = options;

  // Root container
  const root = document.createElement('div');
  root.className = joinClassNames('we-input-container', rootClassName);

  // Prefix element (created lazily)
  let prefixEl: HTMLSpanElement | null = null;

  // Input element
  const input = document.createElement('input');
  input.type = type;
  input.className = joinClassNames('we-input-container__input', inputClassName);
  input.setAttribute('autocomplete', autocomplete);
  input.spellcheck = spellcheck;
  input.setAttribute('aria-label', ariaLabel);
  if (inputMode) {
    input.inputMode = inputMode;
  }
  if (placeholder !== undefined) {
    input.placeholder = placeholder;
  }

  // Suffix element (created lazily)
  let suffixEl: HTMLSpanElement | null = null;

  // Helper: create/update affix element
  function updateAffix(
    kind: 'prefix' | 'suffix',
    content: InputAffix | null,
    existingEl: HTMLSpanElement | null,
  ): HTMLSpanElement | null {
    if (!hasAffix(content)) {
      // Remove existing element if present
      if (existingEl) {
        existingEl.remove();
      }
      return null;
    }

    // Create element if needed
    const el = existingEl ?? document.createElement('span');
    el.className = `we-input-container__${kind}`;

    // Clear and set content
    el.textContent = '';
    if (typeof content === 'string') {
      el.textContent = content;
    } else {
      el.append(content);
    }

    return el;
  }

  // Initial prefix
  if (hasAffix(prefix)) {
    prefixEl = updateAffix('prefix', prefix, null);
    if (prefixEl) root.append(prefixEl);
  }

  // Append input
  root.append(input);

  // Initial suffix
  if (hasAffix(suffix)) {
    suffixEl = updateAffix('suffix', suffix, null);
    if (suffixEl) root.append(suffixEl);
  }

  // Public interface
  return {
    root,
    input,

    setPrefix(content: InputAffix | null): void {
      const newEl = updateAffix('prefix', content, prefixEl);
      if (newEl && !prefixEl) {
        // Insert before input
        root.insertBefore(newEl, input);
      }
      prefixEl = newEl;
    },

    setSuffix(content: InputAffix | null): void {
      const newEl = updateAffix('suffix', content, suffixEl);
      if (newEl && !suffixEl) {
        // Append after input
        root.append(newEl);
      }
      suffixEl = newEl;
    },

    getSuffixText(): string | null {
      if (!suffixEl) return null;
      // Only return text content, not Node content
      const text = suffixEl.textContent?.trim();
      return text || null;
    },
  };
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts:
--------------------------------------------------------------------------------

```typescript
// expression.ts — minimal safe boolean expression evaluator (no access to global scope)
// Supported:
// - Literals: numbers (123, 1.23), strings ('x' or "x"), booleans (true/false)
// - Variables: vars.x, vars.a.b (only reads from provided vars object)
// - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, /
// - Parentheses: ( ... )

type Token = { type: string; value?: any };

function tokenize(input: string): Token[] {
  const s = input.trim();
  const out: Token[] = [];
  let i = 0;
  const isAlpha = (c: string) => /[a-zA-Z_]/.test(c);
  const isNum = (c: string) => /[0-9]/.test(c);
  const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c);
  while (i < s.length) {
    const c = s[i];
    if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
      i++;
      continue;
    }
    // operators
    if (
      s.startsWith('&&', i) ||
      s.startsWith('||', i) ||
      s.startsWith('==', i) ||
      s.startsWith('!=', i) ||
      s.startsWith('>=', i) ||
      s.startsWith('<=', i)
    ) {
      out.push({ type: 'op', value: s.slice(i, i + 2) });
      i += 2;
      continue;
    }
    if ('!+-*/()<>'.includes(c)) {
      out.push({ type: 'op', value: c });
      i++;
      continue;
    }
    // number
    if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) {
      let j = i + 1;
      while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++;
      out.push({ type: 'num', value: parseFloat(s.slice(i, j)) });
      i = j;
      continue;
    }
    // string
    if (c === '"' || c === "'") {
      const quote = c;
      let j = i + 1;
      let str = '';
      while (j < s.length) {
        if (s[j] === '\\' && j + 1 < s.length) {
          str += s[j + 1];
          j += 2;
        } else if (s[j] === quote) {
          j++;
          break;
        } else {
          str += s[j++];
        }
      }
      out.push({ type: 'str', value: str });
      i = j;
      continue;
    }
    // identifier (vars or true/false)
    if (isAlpha(c)) {
      let j = i + 1;
      while (j < s.length && isIdChar(s[j])) j++;
      let id = s.slice(i, j);
      // dotted path
      while (s[j] === '.' && isAlpha(s[j + 1] || '')) {
        let k = j + 1;
        while (k < s.length && isIdChar(s[k])) k++;
        id += s.slice(j, k);
        j = k;
      }
      out.push({ type: 'id', value: id });
      i = j;
      continue;
    }
    // unknown token, skip to avoid crash
    i++;
  }
  return out;
}

// Recursive descent parser
export function evalExpression(expr: string, scope: { vars: Record<string, any> }): any {
  const tokens = tokenize(expr);
  let i = 0;
  const peek = () => tokens[i];
  const consume = () => tokens[i++];

  function parsePrimary(): any {
    const t = peek();
    if (!t) return undefined;
    if (t.type === 'num') {
      consume();
      return t.value;
    }
    if (t.type === 'str') {
      consume();
      return t.value;
    }
    if (t.type === 'id') {
      consume();
      const id = String(t.value);
      if (id === 'true') return true;
      if (id === 'false') return false;
      // Only allow vars.* lookups
      if (!id.startsWith('vars')) return undefined;
      try {
        const parts = id.split('.').slice(1);
        let cur: any = scope.vars;
        for (const p of parts) {
          if (cur == null) return undefined;
          cur = cur[p];
        }
        return cur;
      } catch {
        return undefined;
      }
    }
    if (t.type === 'op' && t.value === '(') {
      consume();
      const v = parseOr();
      if (peek()?.type === 'op' && peek()?.value === ')') consume();
      return v;
    }
    return undefined;
  }

  function parseUnary(): any {
    const t = peek();
    if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) {
      consume();
      const v = parseUnary();
      return t.value === '!' ? !truthy(v) : -Number(v || 0);
    }
    return parsePrimary();
  }

  function parseMulDiv(): any {
    let v = parseUnary();
    while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
      const op = consume().value;
      const r = parseUnary();
      v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0);
    }
    return v;
  }

  function parseAddSub(): any {
    let v = parseMulDiv();
    while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
      const op = consume().value;
      const r = parseMulDiv();
      v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0);
    }
    return v;
  }

  function parseRel(): any {
    let v = parseAddSub();
    while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) {
      const op = consume().value as string;
      const r = parseAddSub();
      const a = toComparable(v);
      const b = toComparable(r);
      if (op === '>') v = (a as any) > (b as any);
      else if (op === '>=') v = (a as any) >= (b as any);
      else if (op === '<') v = (a as any) < (b as any);
      else v = (a as any) <= (b as any);
    }
    return v;
  }

  function parseEq(): any {
    let v = parseRel();
    while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) {
      const op = consume().value as string;
      const r = parseRel();
      const a = toComparable(v);
      const b = toComparable(r);
      v = op === '==' ? a === b : a !== b;
    }
    return v;
  }

  function parseAnd(): any {
    let v = parseEq();
    while (peek() && peek().type === 'op' && peek().value === '&&') {
      consume();
      const r = parseEq();
      v = truthy(v) && truthy(r);
    }
    return v;
  }

  function parseOr(): any {
    let v = parseAnd();
    while (peek() && peek().type === 'op' && peek().value === '||') {
      consume();
      const r = parseAnd();
      v = truthy(v) || truthy(r);
    }
    return v;
  }

  function truthy(v: any) {
    return !!v;
  }
  function toComparable(v: any) {
    return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v);
  }

  try {
    const res = parseOr();
    return res;
  } catch {
    return false;
  }
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Fill Action Handler
 *
 * Handles form input actions:
 * - Text input
 * - File upload
 * - Auto-scroll and focus
 * - Selector fallback with logging
 */

import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler } from '../types';
import {
  ensureElementVisible,
  logSelectorFallback,
  resolveString,
  selectorLocator,
  sendMessageToTab,
  toSelectorTarget,
} from './common';

export const fillHandler: ActionHandler<'fill'> = {
  type: 'fill',

  validate: (action) => {
    const target = action.params.target as { ref?: string; candidates?: unknown[] };
    const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;
    const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;
    const hasValue = action.params.value !== undefined;

    if (!hasValue) {
      return invalid('Missing value parameter');
    }
    if (!hasRef && !hasCandidates) {
      return invalid('Missing target selector or ref');
    }
    return ok();
  },

  describe: (action) => {
    const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';
    const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;
    return `Fill "${displayValue}"`;
  },

  run: async (ctx, action) => {
    const vars = ctx.vars;
    const tabId = ctx.tabId;

    if (typeof tabId !== 'number') {
      return failed('TAB_NOT_FOUND', 'No active tab found');
    }

    // Ensure page is read before locating element
    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });

    // Resolve fill value
    const valueResolved = resolveString(action.params.value, vars);
    if (!valueResolved.ok) {
      return failed('VALIDATION_ERROR', valueResolved.error);
    }
    const value = valueResolved.value;

    // Locate target element
    const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(
      action.params.target,
      vars,
    );

    const located = await selectorLocator.locate(tabId, selectorTarget, {
      frameId: ctx.frameId,
      preferRef: false,
    });

    const frameId = located?.frameId ?? ctx.frameId;
    const refToUse = located?.ref ?? selectorTarget.ref;
    const cssSelector = !located?.ref ? firstCssOrAttr : undefined;

    if (!refToUse && !cssSelector) {
      return failed('TARGET_NOT_FOUND', 'Could not locate target element');
    }

    // Verify element visibility if we have a ref
    if (located?.ref) {
      const isVisible = await ensureElementVisible(tabId, located.ref, frameId);
      if (!isVisible) {
        return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
      }
    }

    // Check for file input and handle file upload
    // Use firstCssOrAttr to check input type even when ref is available
    const selectorForTypeCheck = firstCssOrAttr || cssSelector;
    if (selectorForTypeCheck) {
      const attrResult = await sendMessageToTab<{ value?: string }>(
        tabId,
        { action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },
        frameId,
      );
      const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();

      if (inputType === 'file') {
        const uploadResult = await handleCallTool({
          name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
          args: { selector: selectorForTypeCheck, filePath: value, tabId },
        });

        if ((uploadResult as { isError?: boolean })?.isError) {
          const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;
          const errorMsg = errorContent?.[0]?.text || 'File upload failed';
          return failed('UNKNOWN', errorMsg);
        }

        // Log fallback if used
        const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
        const fallbackUsed =
          resolvedBy &&
          firstCandidateType &&
          resolvedBy !== 'ref' &&
          resolvedBy !== firstCandidateType;
        if (fallbackUsed) {
          logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
        }

        return { status: 'success' };
      }
    }

    // Scroll element into view (best-effort)
    if (cssSelector) {
      try {
        await handleCallTool({
          name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
          args: {
            type: 'MAIN',
            jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,
            tabId,
          },
        });
      } catch {
        // Ignore scroll errors
      }
    }

    // Focus element (best-effort, ignore errors)
    if (located?.ref) {
      await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);
    } else if (cssSelector) {
      await handleCallTool({
        name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
        args: {
          type: 'MAIN',
          jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
          tabId,
        },
      });
    }

    // Execute fill
    const fillResult = await handleCallTool({
      name: TOOL_NAMES.BROWSER.FILL,
      args: {
        ref: refToUse,
        selector: cssSelector,
        value,
        frameId,
        tabId,
      },
    });

    if ((fillResult as { isError?: boolean })?.isError) {
      const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;
      const errorMsg = errorContent?.[0]?.text || 'Fill action failed';
      return failed('UNKNOWN', errorMsg);
    }

    // Log fallback if used
    const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');
    const fallbackUsed =
      resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;

    if (fallbackUsed) {
      logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
    }

    return { status: 'success' };
  },
};

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue:
--------------------------------------------------------------------------------

```vue
<template>
  <div class="flex items-center justify-between w-full">
    <!-- Brand / Context -->
    <div class="flex items-center gap-2 overflow-hidden -ml-1">
      <!-- Back Button (when in chat view) -->
      <button
        v-if="showBackButton"
        class="flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn"
        :style="{
          color: 'var(--ac-text-muted)',
          borderRadius: 'var(--ac-radius-button)',
        }"
        title="Back to sessions"
        @click="$emit('back')"
      >
        <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M15 19l-7-7 7-7"
          />
        </svg>
      </button>

      <!-- Brand -->
      <h1
        class="text-lg font-medium tracking-tight flex-shrink-0"
        :style="{
          fontFamily: 'var(--ac-font-heading)',
          color: 'var(--ac-text)',
        }"
      >
        {{ brandLabel || 'Agent' }}
      </h1>

      <!-- Divider -->
      <div
        class="h-4 w-[1px] flex-shrink-0"
        :style="{ backgroundColor: 'var(--ac-border-strong)' }"
      />

      <!-- Project Breadcrumb -->
      <button
        class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
        :style="{
          fontFamily: 'var(--ac-font-mono)',
          color: 'var(--ac-text-muted)',
          borderRadius: 'var(--ac-radius-button)',
        }"
        @click="$emit('toggle:projectMenu')"
      >
        <span class="truncate">{{ projectLabel }}</span>
        <svg
          class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M19 9l-7 7-7-7"
          />
        </svg>
      </button>

      <!-- Session Breadcrumb -->
      <div class="h-3 w-[1px] flex-shrink-0" :style="{ backgroundColor: 'var(--ac-border)' }" />
      <button
        class="flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn"
        :style="{
          fontFamily: 'var(--ac-font-mono)',
          color: 'var(--ac-text-subtle)',
          borderRadius: 'var(--ac-radius-button)',
        }"
        @click="$emit('toggle:sessionMenu')"
      >
        <span class="truncate">{{ sessionLabel }}</span>
        <svg
          class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M19 9l-7 7-7-7"
          />
        </svg>
      </button>
    </div>

    <!-- Connection / Status / Settings -->
    <div class="flex items-center gap-3">
      <!-- Connection Indicator -->
      <div class="flex items-center gap-1.5" :title="connectionText">
        <span
          class="w-2 h-2 rounded-full"
          :style="{
            backgroundColor: connectionColor,
            boxShadow: connectionState === 'ready' ? `0 0 8px ${connectionColor}` : 'none',
          }"
        />
      </div>

      <!-- Open Project Button -->
      <button
        class="p-1 ac-btn ac-hover-text"
        :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
        title="Open project in VS Code or Terminal"
        @click="$emit('toggle:openProjectMenu')"
      >
        <svg
          class="w-5 h-5"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
          <line x1="12" y1="11" x2="12" y2="17" />
          <line x1="9" y1="14" x2="15" y2="14" />
        </svg>
      </button>

      <!-- Theme & Settings Icon (Color Palette) -->
      <button
        class="p-1 ac-btn ac-hover-text"
        :style="{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }"
        @click="$emit('toggle:settingsMenu')"
      >
        <svg
          class="w-5 h-5"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
          <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
          <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
          <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
          <path
            d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
          />
        </svg>
      </button>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { computed } from 'vue';

export type ConnectionState = 'ready' | 'connecting' | 'disconnected';

const props = defineProps<{
  projectLabel: string;
  sessionLabel: string;
  connectionState: ConnectionState;
  /** Whether to show back button (for returning to sessions list) */
  showBackButton?: boolean;
  /** Brand label to display (e.g., "Claude Code", "Codex") */
  brandLabel?: string;
}>();

defineEmits<{
  'toggle:projectMenu': [];
  'toggle:sessionMenu': [];
  'toggle:settingsMenu': [];
  'toggle:openProjectMenu': [];
  /** Emitted when back button is clicked */
  back: [];
}>();

const connectionColor = computed(() => {
  switch (props.connectionState) {
    case 'ready':
      return 'var(--ac-success)';
    case 'connecting':
      return 'var(--ac-warning)';
    default:
      return 'var(--ac-text-subtle)';
  }
});

const connectionText = computed(() => {
  switch (props.connectionState) {
    case 'ready':
      return 'Connected';
    case 'connecting':
      return 'Connecting...';
    default:
      return 'Disconnected';
  }
});
</script>

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * @fileoverview Interval Trigger Handler (M3.1)
 * @description
 * 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
 *
 * 策略:
 * - 每个触发器对应一个重复 alarm
 * - 使用 delayInMinutes 使首次触发在配置的间隔后
 */

import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';

// ==================== Types ====================

type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;

export interface IntervalTriggerHandlerDeps {
  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}

interface InstalledIntervalTrigger {
  spec: IntervalTriggerSpec;
  periodMinutes: number;
  version: number;
}

// ==================== Constants ====================

const ALARM_PREFIX = 'rr_v3_interval_';

// ==================== Utilities ====================

/**
 * 校验并规范化 periodMinutes
 */
function normalizePeriodMinutes(value: unknown): number {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    throw new Error('periodMinutes must be a finite number');
  }
  if (value < 1) {
    throw new Error('periodMinutes must be >= 1');
  }
  return value;
}

/**
 * 生成 alarm 名称
 */
function alarmNameForTrigger(triggerId: TriggerId): string {
  return `${ALARM_PREFIX}${triggerId}`;
}

/**
 * 从 alarm 名称解析 triggerId
 */
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
  if (!name.startsWith(ALARM_PREFIX)) return null;
  const id = name.slice(ALARM_PREFIX.length);
  return id ? (id as TriggerId) : null;
}

// ==================== Handler Implementation ====================

/**
 * 创建 interval 触发器处理器工厂
 */
export function createIntervalTriggerHandlerFactory(
  deps?: IntervalTriggerHandlerDeps,
): TriggerHandlerFactory<'interval'> {
  return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
}

/**
 * 创建 interval 触发器处理器
 */
export function createIntervalTriggerHandler(
  fireCallback: TriggerFireCallback,
  deps?: IntervalTriggerHandlerDeps,
): TriggerHandler<'interval'> {
  const logger = deps?.logger ?? console;

  const installed = new Map<TriggerId, InstalledIntervalTrigger>();
  const versions = new Map<TriggerId, number>();
  let listening = false;

  /**
   * 递增版本号以使挂起的操作失效
   */
  function bumpVersion(triggerId: TriggerId): number {
    const next = (versions.get(triggerId) ?? 0) + 1;
    versions.set(triggerId, next);
    return next;
  }

  /**
   * 清除指定 alarm
   */
  async function clearAlarmByName(name: string): Promise<void> {
    if (!chrome.alarms?.clear) return;
    try {
      await Promise.resolve(chrome.alarms.clear(name));
    } catch (e) {
      logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
    }
  }

  /**
   * 清除所有 interval alarms
   */
  async function clearAllIntervalAlarms(): Promise<void> {
    if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
    try {
      const alarms = await Promise.resolve(chrome.alarms.getAll());
      const list = Array.isArray(alarms) ? alarms : [];
      await Promise.all(
        list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
      );
    } catch (e) {
      logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
    }
  }

  /**
   * 调度 alarm
   */
  async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
    if (!chrome.alarms?.create) {
      logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
      return;
    }

    const entry = installed.get(triggerId);
    if (!entry || entry.version !== expectedVersion) return;

    const name = alarmNameForTrigger(triggerId);
    const periodInMinutes = entry.periodMinutes;

    try {
      // 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
      // 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
      await Promise.resolve(
        chrome.alarms.create(name, {
          delayInMinutes: periodInMinutes,
          periodInMinutes,
        }),
      );
    } catch (e) {
      logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
    }
  }

  /**
   * Alarm 事件处理
   */
  const onAlarm = (alarm: chrome.alarms.Alarm): void => {
    const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
    if (!triggerId) return;

    const entry = installed.get(triggerId);
    if (!entry) return;

    // 触发回调
    Promise.resolve(
      fireCallback.onFire(triggerId, {
        sourceTabId: undefined,
        sourceUrl: undefined,
      }),
    ).catch((e) => {
      logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
    });
  };

  /**
   * 确保正在监听 alarm 事件
   */
  function ensureListening(): void {
    if (listening) return;
    if (!chrome.alarms?.onAlarm?.addListener) {
      logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
      return;
    }
    chrome.alarms.onAlarm.addListener(onAlarm);
    listening = true;
  }

  /**
   * 停止监听 alarm 事件
   */
  function stopListening(): void {
    if (!listening) return;
    try {
      chrome.alarms.onAlarm.removeListener(onAlarm);
    } catch (e) {
      logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
    } finally {
      listening = false;
    }
  }

  return {
    kind: 'interval',

    async install(trigger: IntervalTriggerSpec): Promise<void> {
      const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);

      const version = bumpVersion(trigger.id);
      installed.set(trigger.id, {
        spec: { ...trigger, periodMinutes },
        periodMinutes,
        version,
      });

      ensureListening();
      await schedule(trigger.id, version);
    },

    async uninstall(triggerId: string): Promise<void> {
      const id = triggerId as TriggerId;
      bumpVersion(id);
      installed.delete(id);
      await clearAlarmByName(alarmNameForTrigger(id));

      if (installed.size === 0) {
        stopListening();
      }
    },

    async uninstallAll(): Promise<void> {
      for (const id of installed.keys()) {
        bumpVersion(id);
      }
      installed.clear();
      await clearAllIntervalAlarms();
      stopListening();
    },

    getInstalledIds(): string[] {
      return Array.from(installed.keys());
    },
  };
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/image-utils.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Image processing utility functions
 */

/**
 * Create ImageBitmap from data URL (for OffscreenCanvas)
 * @param dataUrl Image data URL
 * @returns Created ImageBitmap object
 */
export async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {
  const response = await fetch(dataUrl);
  const blob = await response.blob();
  return await createImageBitmap(blob);
}

/**
 * Stitch multiple image parts (dataURL) onto a single canvas
 * @param parts Array of image parts, each containing dataUrl and y coordinate
 * @param totalWidthPx Total width (pixels)
 * @param totalHeightPx Total height (pixels)
 * @returns Stitched canvas
 */
export async function stitchImages(
  parts: { dataUrl: string; y: number }[],
  totalWidthPx: number,
  totalHeightPx: number,
): Promise<OffscreenCanvas> {
  const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    throw new Error('Unable to get canvas context');
  }

  ctx.fillStyle = '#FFFFFF';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const part of parts) {
    try {
      const img = await createImageBitmapFromUrl(part.dataUrl);
      const sx = 0;
      const sy = 0;
      const sWidth = img.width;
      let sHeight = img.height;
      const dy = part.y;

      if (dy + sHeight > totalHeightPx) {
        sHeight = totalHeightPx - dy;
      }

      if (sHeight <= 0) continue;

      ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);
    } catch (error) {
      console.error('Error stitching image part:', error, part);
    }
  }
  return canvas;
}

/**
 * Crop image (from dataURL) to specified rectangle and resize
 * @param originalDataUrl Original image data URL
 * @param cropRectPx Crop rectangle (physical pixels)
 * @param dpr Device pixel ratio
 * @param targetWidthOpt Optional target output width (CSS pixels)
 * @param targetHeightOpt Optional target output height (CSS pixels)
 * @returns Cropped canvas
 */
export async function cropAndResizeImage(
  originalDataUrl: string,
  cropRectPx: { x: number; y: number; width: number; height: number },
  dpr: number = 1,
  targetWidthOpt?: number,
  targetHeightOpt?: number,
): Promise<OffscreenCanvas> {
  const img = await createImageBitmapFromUrl(originalDataUrl);

  let sx = cropRectPx.x;
  let sy = cropRectPx.y;
  let sWidth = cropRectPx.width;
  let sHeight = cropRectPx.height;

  // Ensure crop area is within image boundaries
  if (sx < 0) {
    sWidth += sx;
    sx = 0;
  }
  if (sy < 0) {
    sHeight += sy;
    sy = 0;
  }
  if (sx + sWidth > img.width) {
    sWidth = img.width - sx;
  }
  if (sy + sHeight > img.height) {
    sHeight = img.height - sy;
  }

  if (sWidth <= 0 || sHeight <= 0) {
    throw new Error(
      'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',
    );
  }

  const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;
  const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;

  const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    throw new Error('Unable to get canvas context');
  }

  ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);

  return canvas;
}

/**
 * Convert canvas to data URL
 * @param canvas Canvas
 * @param format Image format
 * @param quality JPEG quality (0-1)
 * @returns Data URL
 */
export async function canvasToDataURL(
  canvas: OffscreenCanvas,
  format: string = 'image/png',
  quality?: number,
): Promise<string> {
  const blob = await canvas.convertToBlob({
    type: format,
    quality: format === 'image/jpeg' ? quality : undefined,
  });

  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result as string);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

/**
 * Compresses an image by scaling it and converting it to a target format with a specific quality.
 * This is the most effective way to reduce image data size for transport or storage.
 *
 * @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).
 * @param {object} options - Compression options.
 * @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).
 * @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).
 * @param {string} [options.format='image/jpeg'] - The target image format.
 * @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.
 */
export async function compressImage(
  imageDataUrl: string,
  options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },
): Promise<{ dataUrl: string; mimeType: string }> {
  const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;

  // 1. Create an ImageBitmap from the original data URL for efficient drawing.
  const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);

  // 2. Calculate the new dimensions based on the scale factor.
  const newWidth = Math.round(imageBitmap.width * scale);
  const newHeight = Math.round(imageBitmap.height * scale);

  // 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.
  const canvas = new OffscreenCanvas(newWidth, newHeight);
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    throw new Error('Failed to get 2D context from OffscreenCanvas');
  }

  // 4. Draw the original image onto the smaller canvas, effectively resizing it.
  ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);

  // 5. Export the canvas content to the target format with the specified quality.
  // This is the step that performs the data compression.
  const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });

  // A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet
  // on all execution contexts (like service workers).
  const dataUrl = await new Promise<string>((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result as string);
    reader.readAsDataURL(compressedDataUrl);
  });

  return { dataUrl, mimeType: format };
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Key Action Handler
 *
 * Handles keyboard input:
 * - Resolves key sequences via variables/templates
 * - Optionally focuses a target element before sending keys
 * - Dispatches keyboard events via the keyboard tool
 */

import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { handleCallTool } from '@/entrypoints/background/tools';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { failed, invalid, ok } from '../registry';
import type { ActionHandler, ElementTarget } from '../types';
import {
  ensureElementVisible,
  logSelectorFallback,
  resolveString,
  selectorLocator,
  sendMessageToTab,
  toSelectorTarget,
} from './common';

/** Extract error text from tool result */
function extractToolError(result: unknown, fallback: string): string {
  const content = (result as { content?: Array<{ text?: string }> })?.content;
  return content?.find((c) => typeof c?.text === 'string')?.text || fallback;
}

/** Check if target has valid selector specification */
function hasTargetSpec(target: unknown): boolean {
  if (!target || typeof target !== 'object') return false;
  const t = target as { ref?: unknown; candidates?: unknown };
  const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;
  const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;
  return hasRef || hasCandidates;
}

/** Strip frame prefix from composite selector */
function stripCompositeSelector(selector: string): string {
  const raw = String(selector || '').trim();
  if (!raw || !raw.includes('|>')) return raw;
  const parts = raw
    .split('|>')
    .map((p) => p.trim())
    .filter(Boolean);
  return parts.length > 0 ? parts[parts.length - 1] : raw;
}

export const keyHandler: ActionHandler<'key'> = {
  type: 'key',

  validate: (action) => {
    if (action.params.keys === undefined) {
      return invalid('Missing keys parameter');
    }

    if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {
      return invalid('Target must include a non-empty ref or selector candidates');
    }

    return ok();
  },

  describe: (action) => {
    const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';
    const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;
    return `Keys "${display}"`;
  },

  run: async (ctx, action) => {
    const vars = ctx.vars;
    const tabId = ctx.tabId;

    if (typeof tabId !== 'number') {
      return failed('TAB_NOT_FOUND', 'No active tab found for key action');
    }

    // Resolve keys string
    const keysResolved = resolveString(action.params.keys, vars);
    if (!keysResolved.ok) {
      return failed('VALIDATION_ERROR', keysResolved.error);
    }

    const keys = keysResolved.value.trim();
    if (!keys) {
      return failed('VALIDATION_ERROR', 'Keys string is empty');
    }

    let frameId = ctx.frameId;
    let selectorForTool: string | undefined;
    let firstCandidateType: string | undefined;
    let resolvedBy: string | undefined;

    // Handle optional target focusing
    const target = action.params.target as ElementTarget | undefined;
    if (target) {
      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });

      const {
        selectorTarget,
        firstCandidateType: firstType,
        firstCssOrAttr,
      } = toSelectorTarget(target, vars);
      firstCandidateType = firstType;

      const located = await selectorLocator.locate(tabId, selectorTarget, {
        frameId: ctx.frameId,
        preferRef: false,
      });

      frameId = located?.frameId ?? ctx.frameId;
      const refToUse = located?.ref ?? selectorTarget.ref;

      if (!refToUse && !firstCssOrAttr) {
        return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');
      }

      resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');

      // Only verify visibility for freshly located refs (not stale refs from payload)
      if (located?.ref) {
        const visible = await ensureElementVisible(tabId, located.ref, frameId);
        if (!visible) {
          return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');
        }

        const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(
          tabId,
          { action: 'focusByRef', ref: located.ref },
          frameId,
        );

        if (!focusResult.ok || focusResult.value?.success !== true) {
          const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;

          if (!firstCssOrAttr) {
            return failed(
              'TARGET_NOT_FOUND',
              `Failed to focus target element: ${focusErr || 'ref may be stale'}`,
            );
          }

          ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');
        }

        // Try to resolve ref to CSS selector for tool
        const resolved = await sendMessageToTab<{
          success?: boolean;
          selector?: string;
          error?: string;
        }>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);

        if (
          resolved.ok &&
          resolved.value?.success !== false &&
          typeof resolved.value?.selector === 'string'
        ) {
          const sel = resolved.value.selector.trim();
          if (sel) selectorForTool = sel;
        }
      }

      // Fallback to CSS/attr selector
      if (!selectorForTool && firstCssOrAttr) {
        const stripped = stripCompositeSelector(firstCssOrAttr);
        if (stripped) selectorForTool = stripped;
      }
    }

    // Execute keyboard input
    const keyboardResult = await handleCallTool({
      name: TOOL_NAMES.BROWSER.KEYBOARD,
      args: {
        keys,
        selector: selectorForTool,
        selectorType: selectorForTool ? 'css' : undefined,
        tabId,
        frameId,
      },
    });

    if ((keyboardResult as { isError?: boolean })?.isError) {
      return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));
    }

    // Log fallback after successful execution
    const fallbackUsed =
      resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;
    if (fallbackUsed) {
      logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));
    }

    return { status: 'success' };
  },
};

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Wait Action Handler
 *
 * Handles various wait conditions:
 * - Sleep (fixed delay)
 * - Network idle
 * - Navigation complete
 * - Text appears/disappears
 * - Selector visible/hidden
 */

import { ENGINE_CONSTANTS } from '../../engine/constants';
import { waitForNavigation, waitForNetworkIdle } from '../../rr-utils';
import { failed, invalid, ok, tryResolveNumber } from '../registry';
import type { ActionHandler } from '../types';
import { clampInt, resolveString, sendMessageToTab } from './common';

export const waitHandler: ActionHandler<'wait'> = {
  type: 'wait',

  validate: (action) => {
    const condition = action.params.condition;
    if (!condition || typeof condition !== 'object') {
      return invalid('Missing condition parameter');
    }
    if (!('kind' in condition)) {
      return invalid('Condition must have a kind property');
    }
    return ok();
  },

  describe: (action) => {
    const condition = action.params.condition;
    if (!condition) return 'Wait';

    switch (condition.kind) {
      case 'sleep': {
        const ms = typeof condition.sleep === 'number' ? condition.sleep : '(dynamic)';
        return `Wait ${ms}ms`;
      }
      case 'networkIdle':
        return 'Wait for network idle';
      case 'navigation':
        return 'Wait for navigation';
      case 'text': {
        const appear = condition.appear !== false;
        const text = typeof condition.text === 'string' ? condition.text : '(dynamic)';
        const displayText = text.length > 20 ? text.slice(0, 20) + '...' : text;
        return `Wait for text "${displayText}" to ${appear ? 'appear' : 'disappear'}`;
      }
      case 'selector': {
        const visible = condition.visible !== false;
        return `Wait for selector to be ${visible ? 'visible' : 'hidden'}`;
      }
      default:
        return 'Wait';
    }
  },

  run: async (ctx, action) => {
    const vars = ctx.vars;
    const tabId = ctx.tabId;

    if (typeof tabId !== 'number') {
      return failed('TAB_NOT_FOUND', 'No active tab found');
    }

    const timeoutMs = action.policy?.timeout?.ms;
    const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;
    const condition = action.params.condition;

    // Handle sleep condition
    if (condition.kind === 'sleep') {
      const msResolved = tryResolveNumber(condition.sleep, vars);
      if (!msResolved.ok) {
        return failed('VALIDATION_ERROR', msResolved.error);
      }
      const ms = Math.max(0, Number(msResolved.value ?? 0));
      await new Promise((resolve) => setTimeout(resolve, ms));
      return { status: 'success' };
    }

    // Handle network idle condition
    if (condition.kind === 'networkIdle') {
      const totalMs = clampInt(timeoutMs ?? 5000, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);
      let idleMs: number;

      if (condition.idleMs !== undefined) {
        const idleResolved = tryResolveNumber(condition.idleMs, vars);
        idleMs = idleResolved.ok
          ? clampInt(idleResolved.value, 200, 5000)
          : Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
      } else {
        idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));
      }

      await waitForNetworkIdle(totalMs, idleMs);
      return { status: 'success' };
    }

    // Handle navigation condition
    if (condition.kind === 'navigation') {
      const timeout = timeoutMs === undefined ? undefined : Math.max(0, Number(timeoutMs));
      await waitForNavigation(timeout);
      return { status: 'success' };
    }

    // Handle text condition
    if (condition.kind === 'text') {
      const textResolved = resolveString(condition.text, vars);
      if (!textResolved.ok) {
        return failed('VALIDATION_ERROR', textResolved.error);
      }

      const appear = condition.appear !== false;
      const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);

      // Inject wait helper script
      try {
        await chrome.scripting.executeScript({
          target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
          files: ['inject-scripts/wait-helper.js'],
          world: 'ISOLATED',
        });
      } catch (e) {
        return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
      }

      // Execute wait for text
      const response = await sendMessageToTab<{ success?: boolean }>(
        tabId,
        { action: 'waitForText', text: textResolved.value, appear, timeout },
        ctx.frameId,
      );

      if (!response.ok) {
        return failed('TIMEOUT', `Wait for text failed: ${response.error}`);
      }
      if (response.value?.success !== true) {
        return failed(
          'TIMEOUT',
          `Text "${textResolved.value}" did not ${appear ? 'appear' : 'disappear'} within timeout`,
        );
      }

      return { status: 'success' };
    }

    // Handle selector condition
    if (condition.kind === 'selector') {
      const selectorResolved = resolveString(condition.selector, vars);
      if (!selectorResolved.ok) {
        return failed('VALIDATION_ERROR', selectorResolved.error);
      }

      const visible = condition.visible !== false;
      const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);

      // Inject wait helper script
      try {
        await chrome.scripting.executeScript({
          target: { tabId, frameIds } as chrome.scripting.InjectionTarget,
          files: ['inject-scripts/wait-helper.js'],
          world: 'ISOLATED',
        });
      } catch (e) {
        return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);
      }

      // Execute wait for selector
      const response = await sendMessageToTab<{ success?: boolean }>(
        tabId,
        { action: 'waitForSelector', selector: selectorResolved.value, visible, timeout },
        ctx.frameId,
      );

      if (!response.ok) {
        return failed('TIMEOUT', `Wait for selector failed: ${response.error}`);
      }
      if (response.value?.success !== true) {
        return failed(
          'TIMEOUT',
          `Selector "${selectorResolved.value}" did not become ${visible ? 'visible' : 'hidden'} within timeout`,
        );
      }

      return { status: 'success' };
    }

    return failed(
      'VALIDATION_ERROR',
      `Unsupported wait condition kind: ${(condition as { kind: string }).kind}`,
    );
  },
};

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * @fileoverview ContextMenu Trigger Handler (P4-05)
 * @description
 * Uses `chrome.contextMenus` API to create right-click menu items that fire triggers.
 *
 * Design notes:
 * - Each trigger creates a separate menu item with unique ID
 * - Menu item ID is prefixed with 'rr_v3_' to avoid conflicts
 * - Context types: 'page', 'selection', 'link', 'image', 'video', 'audio', etc.
 * - Captures click info and tab info for trigger context
 */

import type { TriggerId } from '../../domain/ids';
import type { TriggerSpecByKind } from '../../domain/triggers';
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';

// ==================== Types ====================

export interface ContextMenuTriggerHandlerDeps {
  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
}

type ContextMenuTriggerSpec = TriggerSpecByKind<'contextMenu'>;

interface InstalledContextMenuTrigger {
  spec: ContextMenuTriggerSpec;
  menuItemId: string;
}

// ==================== Constants ====================

const MENU_ITEM_PREFIX = 'rr_v3_';

// Default context types if not specified
const DEFAULT_CONTEXTS: chrome.contextMenus.ContextType[] = ['page'];

// ==================== Handler Implementation ====================

/**
 * Create context menu trigger handler factory
 */
export function createContextMenuTriggerHandlerFactory(
  deps?: ContextMenuTriggerHandlerDeps,
): TriggerHandlerFactory<'contextMenu'> {
  return (fireCallback) => createContextMenuTriggerHandler(fireCallback, deps);
}

/**
 * Create context menu trigger handler
 */
export function createContextMenuTriggerHandler(
  fireCallback: TriggerFireCallback,
  deps?: ContextMenuTriggerHandlerDeps,
): TriggerHandler<'contextMenu'> {
  const logger = deps?.logger ?? console;

  // Map menuItemId -> triggerId for fast lookup
  const menuItemIdToTriggerId = new Map<string, TriggerId>();
  const installed = new Map<TriggerId, InstalledContextMenuTrigger>();
  let listening = false;

  /**
   * Generate unique menu item ID for a trigger
   */
  function generateMenuItemId(triggerId: TriggerId): string {
    return `${MENU_ITEM_PREFIX}${triggerId}`;
  }

  /**
   * Handle chrome.contextMenus.onClicked event
   */
  const onClicked = (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): void => {
    const menuItemId = String(info.menuItemId);
    const triggerId = menuItemIdToTriggerId.get(menuItemId);
    if (!triggerId) return;

    const trigger = installed.get(triggerId);
    if (!trigger) return;

    // Fire and forget: chrome event listeners should not block
    Promise.resolve(
      fireCallback.onFire(triggerId, {
        sourceTabId: tab?.id,
        sourceUrl: info.pageUrl ?? tab?.url,
      }),
    ).catch((e) => {
      logger.error(`[ContextMenuTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
    });
  };

  /**
   * Ensure listener is registered
   */
  function ensureListening(): void {
    if (listening) return;
    if (!chrome.contextMenus?.onClicked?.addListener) {
      logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.onClicked is unavailable');
      return;
    }
    chrome.contextMenus.onClicked.addListener(onClicked);
    listening = true;
  }

  /**
   * Stop listening
   */
  function stopListening(): void {
    if (!listening) return;
    try {
      chrome.contextMenus.onClicked.removeListener(onClicked);
    } catch (e) {
      logger.debug('[ContextMenuTriggerHandler] removeListener failed:', e);
    } finally {
      listening = false;
    }
  }

  /**
   * Convert context types from spec to chrome API format
   */
  function normalizeContexts(
    contexts: ReadonlyArray<string> | undefined,
  ): chrome.contextMenus.ContextType[] {
    if (!contexts || contexts.length === 0) {
      return DEFAULT_CONTEXTS;
    }
    return contexts as chrome.contextMenus.ContextType[];
  }

  return {
    kind: 'contextMenu',

    async install(trigger: ContextMenuTriggerSpec): Promise<void> {
      const { id, title, contexts } = trigger;
      const menuItemId = generateMenuItemId(id);

      // Check if chrome.contextMenus.create is available
      if (!chrome.contextMenus?.create) {
        logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.create is unavailable');
        return;
      }

      // Create menu item
      await new Promise<void>((resolve, reject) => {
        chrome.contextMenus.create(
          {
            id: menuItemId,
            title: title,
            contexts: normalizeContexts(contexts),
          },
          () => {
            if (chrome.runtime.lastError) {
              reject(new Error(chrome.runtime.lastError.message));
            } else {
              resolve();
            }
          },
        );
      });

      installed.set(id, { spec: trigger, menuItemId });
      menuItemIdToTriggerId.set(menuItemId, id);
      ensureListening();
    },

    async uninstall(triggerId: string): Promise<void> {
      const trigger = installed.get(triggerId as TriggerId);
      if (!trigger) return;

      // Remove menu item
      if (chrome.contextMenus?.remove) {
        await new Promise<void>((resolve) => {
          chrome.contextMenus.remove(trigger.menuItemId, () => {
            // Ignore errors (item may not exist)
            if (chrome.runtime.lastError) {
              logger.debug(
                `[ContextMenuTriggerHandler] Failed to remove menu item: ${chrome.runtime.lastError.message}`,
              );
            }
            resolve();
          });
        });
      }

      menuItemIdToTriggerId.delete(trigger.menuItemId);
      installed.delete(triggerId as TriggerId);

      if (installed.size === 0) {
        stopListening();
      }
    },

    async uninstallAll(): Promise<void> {
      // Remove all menu items created by this handler
      if (chrome.contextMenus?.remove) {
        const removePromises = Array.from(installed.values()).map(
          (trigger) =>
            new Promise<void>((resolve) => {
              chrome.contextMenus.remove(trigger.menuItemId, () => {
                // Ignore errors
                resolve();
              });
            }),
        );
        await Promise.all(removePromises);
      }

      installed.clear();
      menuItemIdToTriggerId.clear();
      stopListening();
    },

    getInstalledIds(): string[] {
      return Array.from(installed.keys());
    },
  };
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Legacy Step Types for Record & Replay
 *
 * This file contains the legacy Step type system that is being phased out
 * in favor of the DAG-based execution model (nodes/edges).
 *
 * These types are kept for:
 * 1. Backward compatibility with existing flows that use steps array
 * 2. Recording pipeline that still produces Step[] output
 * 3. Legacy node handlers in nodes/ directory
 *
 * New code should use the Action type system from ./actions/types.ts instead.
 *
 * Migration status: P4 phase 1 - types extracted, re-exported from types.ts
 */

import { STEP_TYPES } from '@/common/step-types';

// =============================================================================
// Legacy Selector Types
// =============================================================================

export type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';

export interface SelectorCandidate {
  type: SelectorType;
  value: string; // literal selector or text/aria expression
  weight?: number; // user-adjustable priority; higher first
}

export interface TargetLocator {
  ref?: string; // ephemeral ref from read_page
  candidates: SelectorCandidate[]; // ordered by priority
}

// =============================================================================
// Legacy Step Types
// =============================================================================

export type StepType = (typeof STEP_TYPES)[keyof typeof STEP_TYPES];

export interface StepBase {
  id: string;
  type: StepType;
  timeoutMs?: number; // default 10000
  retry?: { count: number; intervalMs: number; backoff?: 'none' | 'exp' };
  screenshotOnFail?: boolean; // default true
}

export interface StepClick extends StepBase {
  type: 'click' | 'dblclick';
  target: TargetLocator;
  before?: { scrollIntoView?: boolean; waitForSelector?: boolean };
  after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean };
}

export interface StepFill extends StepBase {
  type: 'fill';
  target: TargetLocator;
  value: string; // may contain {var}
}

export interface StepTriggerEvent extends StepBase {
  type: 'triggerEvent';
  target: TargetLocator;
  event: string; // e.g. 'input', 'change', 'mouseover'
  bubbles?: boolean;
  cancelable?: boolean;
}

export interface StepSetAttribute extends StepBase {
  type: 'setAttribute';
  target: TargetLocator;
  name: string;
  value?: string; // when omitted and remove=true, remove attribute
  remove?: boolean;
}

export interface StepScreenshot extends StepBase {
  type: 'screenshot';
  selector?: string;
  fullPage?: boolean;
  saveAs?: string; // variable name to store base64
}

export interface StepSwitchFrame extends StepBase {
  type: 'switchFrame';
  frame?: { index?: number; urlContains?: string };
}

export interface StepLoopElements extends StepBase {
  type: 'loopElements';
  selector: string;
  saveAs?: string; // list var name
  itemVar?: string; // default 'item'
  subflowId: string;
}

export interface StepKey extends StepBase {
  type: 'key';
  keys: string; // e.g. "Backspace Enter" or "cmd+a"
  target?: TargetLocator; // optional focus target
}

export interface StepScroll extends StepBase {
  type: 'scroll';
  mode: 'element' | 'offset' | 'container';
  target?: TargetLocator; // when mode = element / container
  offset?: { x?: number; y?: number };
}

export interface StepDrag extends StepBase {
  type: 'drag';
  start: TargetLocator;
  end: TargetLocator;
  path?: Array<{ x: number; y: number }>; // sampled trajectory
}

export interface StepWait extends StepBase {
  type: 'wait';
  condition:
    | { selector: string; visible?: boolean }
    | { text: string; appear?: boolean }
    | { navigation: true }
    | { networkIdle: true }
    | { sleep: number };
}

export interface StepAssert extends StepBase {
  type: 'assert';
  assert:
    | { exists: string }
    | { visible: string }
    | { textPresent: string }
    | { attribute: { selector: string; name: string; equals?: string; matches?: string } };
  // 失败策略:stop=失败即停(默认)、warn=仅告警并继续、retry=触发重试机制
  failStrategy?: 'stop' | 'warn' | 'retry';
}

export interface StepScript extends StepBase {
  type: 'script';
  world?: 'MAIN' | 'ISOLATED';
  code: string; // user script string
  when?: 'before' | 'after';
}

export interface StepIf extends StepBase {
  type: 'if';
  // condition supports: { var: string; equals?: any } | { expression: string }
  condition: any;
}

export interface StepForeach extends StepBase {
  type: 'foreach';
  listVar: string;
  itemVar?: string;
  subflowId: string;
}

export interface StepWhile extends StepBase {
  type: 'while';
  condition: any;
  subflowId: string;
  maxIterations?: number;
}

export interface StepHttp extends StepBase {
  type: 'http';
  method?: string;
  url: string;
  headers?: Record<string, string>;
  body?: any;
  formData?: any;
  saveAs?: string;
  assign?: Record<string, string>;
}

export interface StepExtract extends StepBase {
  type: 'extract';
  selector?: string;
  attr?: string; // 'text'|'textContent' to read text
  js?: string; // custom JS that returns value
  saveAs: string;
}

export interface StepOpenTab extends StepBase {
  type: 'openTab';
  url?: string;
  newWindow?: boolean;
}

export interface StepSwitchTab extends StepBase {
  type: 'switchTab';
  tabId?: number;
  urlContains?: string;
  titleContains?: string;
}

export interface StepCloseTab extends StepBase {
  type: 'closeTab';
  tabIds?: number[];
  url?: string;
}

export interface StepNavigate extends StepBase {
  type: 'navigate';
  url: string;
}

export interface StepHandleDownload extends StepBase {
  type: 'handleDownload';
  filenameContains?: string;
  saveAs?: string;
  waitForComplete?: boolean;
}

export interface StepExecuteFlow extends StepBase {
  type: 'executeFlow';
  flowId: string;
  inline?: boolean;
  args?: Record<string, any>;
}

// =============================================================================
// Step Union Type
// =============================================================================

export type Step =
  | StepClick
  | StepFill
  | StepTriggerEvent
  | StepSetAttribute
  | StepScreenshot
  | StepSwitchFrame
  | StepLoopElements
  | StepKey
  | StepScroll
  | StepDrag
  | StepWait
  | StepAssert
  | StepScript
  | StepIf
  | StepForeach
  | StepWhile
  | StepNavigate
  | StepHttp
  | StepExtract
  | StepOpenTab
  | StepSwitchTab
  | StepCloseTab
  | StepHandleDownload
  | StepExecuteFlow;

```

--------------------------------------------------------------------------------
/app/native-server/src/agent/message-service.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Message Service - Database-backed implementation using Drizzle ORM.
 *
 * Provides CRUD operations for agent chat messages with:
 * - Type-safe database queries
 * - Efficient indexed queries
 * - Consistent with AgentStoredMessage interface from shared types
 */
import { randomUUID } from 'node:crypto';
import { eq, asc, and, count } from 'drizzle-orm';
import type { AgentRole, AgentStoredMessage } from 'chrome-mcp-shared';
import { getDb, messages, type MessageRow } from './db';

// ============================================================
// Types
// ============================================================

export type { AgentStoredMessage };

export interface CreateAgentStoredMessageInput {
  projectId: string;
  role: AgentRole;
  messageType: AgentStoredMessage['messageType'];
  content: string;
  metadata?: Record<string, unknown>;
  sessionId?: string;
  conversationId?: string | null;
  cliSource?: string;
  requestId?: string;
  id?: string;
  createdAt?: string;
}

// ============================================================
// Type Conversion
// ============================================================

/**
 * Convert database row to AgentStoredMessage interface.
 */
function rowToMessage(row: MessageRow): AgentStoredMessage {
  return {
    id: row.id,
    projectId: row.projectId,
    sessionId: row.sessionId,
    conversationId: row.conversationId,
    role: row.role as AgentRole,
    content: row.content,
    messageType: row.messageType as AgentStoredMessage['messageType'],
    metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
    cliSource: row.cliSource,
    requestId: row.requestId ?? undefined,
    createdAt: row.createdAt,
  };
}

// ============================================================
// Public API
// ============================================================

/**
 * Get messages by project ID with pagination.
 * Returns messages sorted by creation time (oldest first).
 */
export async function getMessagesByProjectId(
  projectId: string,
  limit = 50,
  offset = 0,
): Promise<AgentStoredMessage[]> {
  const db = getDb();

  const query = db
    .select()
    .from(messages)
    .where(eq(messages.projectId, projectId))
    .orderBy(asc(messages.createdAt));

  // Apply pagination if specified
  if (limit > 0) {
    query.limit(limit);
  }
  if (offset > 0) {
    query.offset(offset);
  }

  const rows = await query;
  return rows.map(rowToMessage);
}

/**
 * Get the total count of messages for a project.
 */
export async function getMessagesCountByProjectId(projectId: string): Promise<number> {
  const db = getDb();
  const result = await db
    .select({ count: count() })
    .from(messages)
    .where(eq(messages.projectId, projectId));
  return result[0]?.count ?? 0;
}

/**
 * Create a new message.
 */
export async function createMessage(
  input: CreateAgentStoredMessageInput,
): Promise<AgentStoredMessage> {
  const db = getDb();
  const now = new Date().toISOString();

  const messageData: MessageRow = {
    id: input.id?.trim() || randomUUID(),
    projectId: input.projectId,
    sessionId: input.sessionId || '',
    conversationId: input.conversationId ?? null,
    role: input.role,
    content: input.content,
    messageType: input.messageType,
    metadata: input.metadata ? JSON.stringify(input.metadata) : null,
    cliSource: input.cliSource ?? null,
    requestId: input.requestId ?? null,
    createdAt: input.createdAt || now,
  };

  await db
    .insert(messages)
    .values(messageData)
    .onConflictDoUpdate({
      target: messages.id,
      set: {
        role: messageData.role,
        messageType: messageData.messageType,
        content: messageData.content,
        metadata: messageData.metadata,
        sessionId: messageData.sessionId,
        conversationId: messageData.conversationId,
        cliSource: messageData.cliSource,
        requestId: messageData.requestId,
      },
    });

  return rowToMessage(messageData);
}

/**
 * Delete messages by project ID.
 * Optionally filter by conversation ID.
 * Returns the number of deleted messages.
 */
export async function deleteMessagesByProjectId(
  projectId: string,
  conversationId?: string,
): Promise<number> {
  const db = getDb();

  // Get count before deletion
  const beforeCount = await getMessagesCountByProjectId(projectId);

  if (conversationId) {
    await db
      .delete(messages)
      .where(and(eq(messages.projectId, projectId), eq(messages.conversationId, conversationId)));
  } else {
    await db.delete(messages).where(eq(messages.projectId, projectId));
  }

  // Get count after deletion to calculate deleted count
  const afterCount = await getMessagesCountByProjectId(projectId);
  return beforeCount - afterCount;
}

/**
 * Get messages by session ID with optional pagination.
 * Returns messages sorted by creation time (oldest first).
 *
 * @param sessionId - The session ID to filter by
 * @param limit - Maximum number of messages to return (0 = no limit)
 * @param offset - Number of messages to skip
 */
export async function getMessagesBySessionId(
  sessionId: string,
  limit = 0,
  offset = 0,
): Promise<AgentStoredMessage[]> {
  const db = getDb();

  const query = db
    .select()
    .from(messages)
    .where(eq(messages.sessionId, sessionId))
    .orderBy(asc(messages.createdAt));

  if (limit > 0) {
    query.limit(limit);
  }
  if (offset > 0) {
    query.offset(offset);
  }

  const rows = await query;
  return rows.map(rowToMessage);
}

/**
 * Get count of messages by session ID.
 */
export async function getMessagesCountBySessionId(sessionId: string): Promise<number> {
  const db = getDb();
  const result = await db
    .select({ count: count() })
    .from(messages)
    .where(eq(messages.sessionId, sessionId));
  return result[0]?.count ?? 0;
}

/**
 * Delete all messages for a session.
 * Returns the number of deleted messages.
 */
export async function deleteMessagesBySessionId(sessionId: string): Promise<number> {
  const db = getDb();

  const beforeCount = await getMessagesCountBySessionId(sessionId);
  await db.delete(messages).where(eq(messages.sessionId, sessionId));
  const afterCount = await getMessagesCountBySessionId(sessionId);

  return beforeCount - afterCount;
}

/**
 * Get messages by request ID.
 */
export async function getMessagesByRequestId(requestId: string): Promise<AgentStoredMessage[]> {
  const db = getDb();
  const rows = await db
    .select()
    .from(messages)
    .where(eq(messages.requestId, requestId))
    .orderBy(asc(messages.createdAt));
  return rows.map(rowToMessage);
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/element-picker.content.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Element Picker Content Script
 *
 * Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events
 * to background while a chrome_request_element_selection session is active.
 *
 * This script only runs in the top frame and handles:
 * - Displaying the element picker panel UI
 * - Forwarding user actions (cancel, confirm, etc.) to background
 * - Receiving state updates from background
 */

import {
  createElementPickerController,
  type ElementPickerController,
  type ElementPickerUiState,
} from '@/shared/element-picker';
import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';
import type { PickedElement } from 'chrome-mcp-shared';

// ============================================================
// Message Types
// ============================================================

interface UiShowMessage {
  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW;
  sessionId: string;
  requests: Array<{ id: string; name: string; description?: string }>;
  activeRequestId: string | null;
  deadlineTs: number;
}

interface UiUpdateMessage {
  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE;
  sessionId: string;
  activeRequestId: string | null;
  selections: Record<string, PickedElement | null>;
  deadlineTs: number;
  errorMessage: string | null;
}

interface UiHideMessage {
  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE;
  sessionId: string;
}

interface UiPingMessage {
  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING;
}

type PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage;

// ============================================================
// Content Script Definition
// ============================================================

export default defineContentScript({
  matches: ['<all_urls>'],
  runAt: 'document_idle',

  main() {
    // Only mount UI in the top frame
    if (window.top !== window) return;

    let controller: ElementPickerController | null = null;
    let currentSessionId: string | null = null;

    /**
     * Ensure the controller is created and configured.
     */
    function ensureController(): ElementPickerController {
      if (controller) return controller;

      controller = createElementPickerController({
        onCancel: () => {
          if (!currentSessionId) return;
          void chrome.runtime.sendMessage({
            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
            sessionId: currentSessionId,
            event: 'cancel',
          });
        },
        onConfirm: () => {
          if (!currentSessionId) return;
          void chrome.runtime.sendMessage({
            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
            sessionId: currentSessionId,
            event: 'confirm',
          });
        },
        onSetActiveRequest: (requestId: string) => {
          if (!currentSessionId) return;
          void chrome.runtime.sendMessage({
            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
            sessionId: currentSessionId,
            event: 'set_active_request',
            requestId,
          });
        },
        onClearSelection: (requestId: string) => {
          if (!currentSessionId) return;
          void chrome.runtime.sendMessage({
            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,
            sessionId: currentSessionId,
            event: 'clear_selection',
            requestId,
          });
        },
      });

      return controller;
    }

    /**
     * Handle incoming messages from background.
     */
    function handleMessage(
      message: unknown,
      _sender: chrome.runtime.MessageSender,
      sendResponse: (response?: unknown) => void,
    ): boolean | void {
      const msg = message as PickerMessage | undefined;
      if (!msg?.action) return false;

      // Respond to ping (used by background to check if UI script is ready)
      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) {
        sendResponse({ success: true });
        return true;
      }

      // Show the picker panel
      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) {
        const showMsg = msg as UiShowMessage;
        currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null;

        if (!currentSessionId) {
          sendResponse({ success: false, error: 'Missing sessionId' });
          return true;
        }

        const ctrl = ensureController();
        const initialState: ElementPickerUiState = {
          sessionId: currentSessionId,
          requests: Array.isArray(showMsg.requests) ? showMsg.requests : [],
          activeRequestId: showMsg.activeRequestId ?? null,
          selections: {},
          deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(),
          errorMessage: null,
        };
        ctrl.show(initialState);
        sendResponse({ success: true });
        return true;
      }

      // Update the picker panel state
      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) {
        const updateMsg = msg as UiUpdateMessage;

        if (!currentSessionId || updateMsg.sessionId !== currentSessionId) {
          sendResponse({ success: false, error: 'Session mismatch' });
          return true;
        }

        controller?.update({
          sessionId: currentSessionId,
          activeRequestId: updateMsg.activeRequestId ?? null,
          selections: updateMsg.selections || {},
          deadlineTs: updateMsg.deadlineTs,
          errorMessage: updateMsg.errorMessage ?? null,
        });
        sendResponse({ success: true });
        return true;
      }

      // Hide the picker panel
      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) {
        const hideMsg = msg as UiHideMessage;

        // Best-effort hide even if session mismatches
        if (currentSessionId && hideMsg.sessionId !== currentSessionId) {
          // Log but don't fail
          console.warn('[ElementPicker] Session mismatch on hide, hiding anyway');
        }

        controller?.hide();
        currentSessionId = null;
        sendResponse({ success: true });
        return true;
      }

      return false;
    }

    // Register message listener
    chrome.runtime.onMessage.addListener(handleMessage);

    // Cleanup on page unload
    window.addEventListener('unload', () => {
      chrome.runtime.onMessage.removeListener(handleMessage);
      controller?.dispose();
      controller = null;
      currentSessionId = null;
    });
  },
});

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolResultCardStep.vue:
--------------------------------------------------------------------------------

```vue
<template>
  <div class="space-y-2">
    <!-- Label + Title + Diff Stats -->
    <div class="flex items-baseline gap-2 flex-wrap">
      <span
        class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
        :style="{ color: labelColor }"
      >
        {{ item.tool.label }}
      </span>
      <code
        class="text-xs font-semibold"
        :style="{
          fontFamily: 'var(--ac-font-mono)',
          color: 'var(--ac-text)',
        }"
        :title="item.tool.filePath"
      >
        {{ item.tool.title }}
      </code>
      <!-- Diff Stats Badge -->
      <span
        v-if="hasDiffStats"
        class="text-[10px] px-1.5 py-0.5"
        :style="{
          backgroundColor: 'var(--ac-chip-bg)',
          color: 'var(--ac-text-muted)',
          fontFamily: 'var(--ac-font-mono)',
          borderRadius: 'var(--ac-radius-button)',
        }"
      >
        <span v-if="item.tool.diffStats?.addedLines" class="text-green-600 dark:text-green-400">
          +{{ item.tool.diffStats.addedLines }}
        </span>
        <span v-if="item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines">/</span>
        <span v-if="item.tool.diffStats?.deletedLines" class="text-red-600 dark:text-red-400">
          -{{ item.tool.diffStats.deletedLines }}
        </span>
        <span
          v-if="
            !item.tool.diffStats?.addedLines &&
            !item.tool.diffStats?.deletedLines &&
            item.tool.diffStats?.totalLines
          "
        >
          {{ item.tool.diffStats.totalLines }} lines
        </span>
      </span>
    </div>

    <!-- File Path (if different from title) -->
    <div
      v-if="showFilePath"
      class="text-[10px] pl-10 truncate"
      :style="{ color: 'var(--ac-text-subtle)' }"
      :title="item.tool.filePath"
    >
      {{ item.tool.filePath }}
    </div>

    <!-- Result Card -->
    <div
      v-if="showCard"
      class="overflow-hidden text-xs leading-5"
      :style="{
        fontFamily: 'var(--ac-font-mono)',
        border: 'var(--ac-border-width) solid var(--ac-code-border)',
        boxShadow: 'var(--ac-shadow-card)',
        borderRadius: 'var(--ac-radius-inner)',
      }"
    >
      <!-- File list for edit -->
      <template v-if="item.tool.kind === 'edit' && item.tool.files?.length">
        <div
          v-for="(file, idx) in item.tool.files.slice(0, 5)"
          :key="file"
          class="px-3 py-1"
          :style="{
            backgroundColor: 'var(--ac-surface)',
            borderBottom:
              idx === Math.min(item.tool.files.length, 5) - 1
                ? 'none'
                : 'var(--ac-border-width) solid var(--ac-border)',
            color: 'var(--ac-text-muted)',
          }"
        >
          {{ file }}
        </div>
        <div
          v-if="item.tool.files.length > 5"
          class="px-3 py-1 text-[10px]"
          :style="{
            backgroundColor: 'var(--ac-surface-muted)',
            color: 'var(--ac-text-subtle)',
          }"
        >
          +{{ item.tool.files.length - 5 }} more files
        </div>
      </template>

      <!-- Command output -->
      <template v-else-if="item.tool.kind === 'run' && item.tool.details">
        <div
          class="px-3 py-2 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto ac-scroll"
          :style="{
            backgroundColor: 'var(--ac-code-bg)',
            color: 'var(--ac-code-text)',
          }"
        >
          {{ truncatedDetails }}
        </div>
        <button
          v-if="isDetailsTruncated"
          class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
          :style="{
            backgroundColor: 'var(--ac-surface-muted)',
            color: 'var(--ac-link)',
          }"
          @click="expanded = !expanded"
        >
          {{ expanded ? 'Show less' : 'Show more...' }}
        </button>
      </template>

      <!-- Generic details -->
      <template v-else-if="item.tool.details">
        <div
          class="px-3 py-2 whitespace-pre-wrap break-words max-h-[150px] overflow-y-auto ac-scroll"
          :style="{
            backgroundColor: 'var(--ac-code-bg)',
            color: 'var(--ac-code-text)',
          }"
        >
          {{ truncatedDetails }}
        </div>
        <button
          v-if="isDetailsTruncated"
          class="w-full px-3 py-1 text-[10px] text-left cursor-pointer"
          :style="{
            backgroundColor: 'var(--ac-surface-muted)',
            color: 'var(--ac-link)',
          }"
          @click="expanded = !expanded"
        >
          {{ expanded ? 'Show less' : 'Show more...' }}
        </button>
      </template>
    </div>

    <!-- Error indicator -->
    <div v-if="item.isError" class="text-[11px]" :style="{ color: 'var(--ac-danger)' }">
      Error occurred
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { TimelineItem } from '../../../composables/useAgentThreads';

const props = defineProps<{
  item: Extract<TimelineItem, { kind: 'tool_result' }>;
}>();

const expanded = ref(false);
const MAX_LINES = 10;
const MAX_CHARS = 500;

const labelColor = computed(() => {
  if (props.item.isError) {
    return 'var(--ac-danger)';
  }
  if (props.item.tool.kind === 'edit') {
    return 'var(--ac-accent)';
  }
  return 'var(--ac-success)';
});

const hasDiffStats = computed(() => {
  const stats = props.item.tool.diffStats;
  if (!stats) return false;
  return (
    stats.addedLines !== undefined ||
    stats.deletedLines !== undefined ||
    stats.totalLines !== undefined
  );
});

const showFilePath = computed(() => {
  const tool = props.item.tool;
  // Show full path if title is just the filename
  if (!tool.filePath) return false;
  return tool.filePath !== tool.title && !tool.title.includes('/');
});

const showCard = computed(() => {
  const tool = props.item.tool;
  return (
    (tool.kind === 'edit' && tool.files?.length) ||
    (tool.kind === 'run' && tool.details) ||
    tool.details
  );
});

const isDetailsTruncated = computed(() => {
  const details = props.item.tool.details ?? '';
  const lines = details.split('\n');
  return lines.length > MAX_LINES || details.length > MAX_CHARS;
});

const truncatedDetails = computed(() => {
  const details = props.item.tool.details ?? '';
  if (expanded.value) {
    return details;
  }

  const lines = details.split('\n');
  if (lines.length > MAX_LINES) {
    return lines.slice(0, MAX_LINES).join('\n');
  }
  if (details.length > MAX_CHARS) {
    return details.slice(0, MAX_CHARS);
  }
  return details;
});
</script>

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue:
--------------------------------------------------------------------------------

```vue
<template>
  <div ref="shellRef" class="h-full flex flex-col overflow-hidden relative">
    <!-- Header -->
    <header
      class="flex-none px-5 py-3 flex items-center justify-between z-20"
      :style="{
        backgroundColor: 'var(--ac-header-bg)',
        borderBottom: 'var(--ac-border-width) solid var(--ac-header-border)',
        backdropFilter: 'blur(8px)',
      }"
    >
      <slot name="header" />
    </header>

    <!-- Content Area -->
    <main
      ref="contentRef"
      class="flex-1 overflow-y-auto ac-scroll"
      :style="{
        paddingBottom: composerHeight + 'px',
      }"
      @scroll="handleScroll"
    >
      <!-- Stable wrapper for ResizeObserver -->
      <div ref="contentSlotRef">
        <slot name="content" />
      </div>
    </main>

    <!-- Footer / Composer -->
    <footer
      ref="composerRef"
      class="flex-none px-5 pb-5 pt-2"
      :style="{
        background: `linear-gradient(to top, var(--ac-bg), var(--ac-bg), transparent)`,
      }"
    >
      <!-- Error Banner (above input) -->
      <div
        v-if="errorMessage"
        class="mb-2 px-4 py-2 text-xs rounded-lg flex items-start gap-2"
        :style="{
          backgroundColor: 'var(--ac-diff-del-bg)',
          color: 'var(--ac-danger)',
          border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',
          borderRadius: 'var(--ac-radius-inner)',
        }"
      >
        <!-- Error message with scroll for long content -->
        <div
          class="min-w-0 flex-1 whitespace-pre-wrap break-all ac-scroll"
          :style="{ maxHeight: '30vh', overflowY: 'auto', overflowWrap: 'anywhere' }"
        >
          {{ errorMessage }}
        </div>

        <!-- Dismiss button -->
        <button
          type="button"
          class="p-1 flex-shrink-0 ac-btn ac-focus-ring cursor-pointer"
          :style="{
            color: 'var(--ac-danger)',
            borderRadius: 'var(--ac-radius-button)',
          }"
          aria-label="Dismiss error"
          title="Dismiss"
          @click="emit('error:dismiss')"
        >
          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
      </div>

      <slot name="composer" />

      <!-- Usage & Version label -->
      <div
        class="text-[10px] text-center mt-2 font-medium tracking-wide flex items-center justify-center gap-2"
        :style="{ color: 'var(--ac-text-subtle)' }"
      >
        <template v-if="usage">
          <span
            :title="`Input: ${usage.inputTokens.toLocaleString()}, Output: ${usage.outputTokens.toLocaleString()}`"
          >
            {{ formatTokens(usage.inputTokens + usage.outputTokens) }} tokens
          </span>
          <span class="opacity-50">·</span>
          <span
            :title="`Duration: ${(usage.durationMs / 1000).toFixed(1)}s, Turns: ${usage.numTurns}`"
          >
            ${{ usage.totalCostUsd.toFixed(4) }}
          </span>
          <span class="opacity-50">·</span>
        </template>
        <span>{{ footerLabel || 'Agent Preview' }}</span>
      </div>
    </footer>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from 'vue';
import type { AgentUsageStats } from 'chrome-mcp-shared';

defineProps<{
  errorMessage?: string | null;
  usage?: AgentUsageStats | null;
  /** Footer label to display (e.g., "Claude Code Preview", "Codex Preview") */
  footerLabel?: string;
}>();

const emit = defineEmits<{
  /** Emitted when user clicks dismiss button on error banner */
  'error:dismiss': [];
}>();

/**
 * Format token count for display (e.g., 1.2k, 3.5M)
 */
function formatTokens(count: number): string {
  if (count >= 1_000_000) {
    return (count / 1_000_000).toFixed(1) + 'M';
  }
  if (count >= 1_000) {
    return (count / 1_000).toFixed(1) + 'k';
  }
  return count.toString();
}

const shellRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
const contentSlotRef = ref<HTMLElement | null>(null);
const composerRef = ref<HTMLElement | null>(null);
const composerHeight = ref(120); // Default height

// Auto-scroll state
const isUserScrolledUp = ref(false);
// Threshold should account for padding and some tolerance
const SCROLL_THRESHOLD = 150;

/**
 * Check if scroll position is near bottom
 */
function isNearBottom(el: HTMLElement): boolean {
  const { scrollTop, scrollHeight, clientHeight } = el;
  return scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
}

/**
 * Handle user scroll to track if they've scrolled up
 */
function handleScroll(): void {
  if (!contentRef.value) return;
  isUserScrolledUp.value = !isNearBottom(contentRef.value);
}

/**
 * Scroll to bottom of content area
 */
function scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
  if (!contentRef.value) return;
  contentRef.value.scrollTo({
    top: contentRef.value.scrollHeight,
    behavior,
  });
}

// Observers
let composerResizeObserver: ResizeObserver | null = null;
let contentResizeObserver: ResizeObserver | null = null;

// Scroll scheduling to prevent excessive calls during streaming
let scrollScheduled = false;

/**
 * Auto-scroll when content or composer changes (if user is at bottom)
 * Uses requestAnimationFrame to debounce rapid updates during streaming
 */
function maybeAutoScroll(): void {
  if (scrollScheduled || isUserScrolledUp.value || !contentRef.value) {
    return;
  }
  scrollScheduled = true;
  requestAnimationFrame(() => {
    scrollScheduled = false;
    if (!isUserScrolledUp.value) {
      scrollToBottom('auto');
    }
  });
}

onMounted(() => {
  // Observe composer height changes
  if (composerRef.value) {
    composerResizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        composerHeight.value = entry.contentRect.height + 24; // Add padding
      }
      // Also auto-scroll when composer height changes (e.g., error banner appears)
      maybeAutoScroll();
    });
    composerResizeObserver.observe(composerRef.value);
  }

  // Observe content height changes for auto-scroll using stable wrapper
  if (contentSlotRef.value) {
    contentResizeObserver = new ResizeObserver(() => {
      maybeAutoScroll();
    });
    contentResizeObserver.observe(contentSlotRef.value);
  }
});

onUnmounted(() => {
  composerResizeObserver?.disconnect();
  contentResizeObserver?.disconnect();
});

// Expose scrollToBottom for parent component to call
defineExpose({
  scrollToBottom,
});
</script>

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/ModelCacheManagement.vue:
--------------------------------------------------------------------------------

```vue
<template>
  <div class="model-cache-section">
    <h2 class="section-title">{{ getMessage('modelCacheManagementLabel') }}</h2>

    <!-- Cache Statistics Grid -->
    <div class="stats-grid">
      <div class="stats-card">
        <div class="stats-header">
          <p class="stats-label">{{ getMessage('cacheSizeLabel') }}</p>
          <span class="stats-icon orange">
            <DatabaseIcon />
          </span>
        </div>
        <p class="stats-value">{{ cacheStats?.totalSizeMB || 0 }} MB</p>
      </div>

      <div class="stats-card">
        <div class="stats-header">
          <p class="stats-label">{{ getMessage('cacheEntriesLabel') }}</p>
          <span class="stats-icon purple">
            <VectorIcon />
          </span>
        </div>
        <p class="stats-value">{{ cacheStats?.entryCount || 0 }}</p>
      </div>
    </div>

    <!-- Cache Entries Details -->
    <div v-if="cacheStats && cacheStats.entries.length > 0" class="cache-details">
      <h3 class="cache-details-title">{{ getMessage('cacheDetailsLabel') }}</h3>
      <div class="cache-entries">
        <div v-for="entry in cacheStats.entries" :key="entry.url" class="cache-entry">
          <div class="entry-info">
            <div class="entry-url">{{ getModelNameFromUrl(entry.url) }}</div>
            <div class="entry-details">
              <span class="entry-size">{{ entry.sizeMB }} MB</span>
              <span class="entry-age">{{ entry.age }}</span>
              <span v-if="entry.expired" class="entry-expired">{{ getMessage('expiredLabel') }}</span>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- No Cache Message -->
    <div v-else-if="cacheStats && cacheStats.entries.length === 0" class="no-cache">
      <p>{{ getMessage('noCacheDataMessage') }}</p>
    </div>

    <!-- Loading State -->
    <div v-else-if="!cacheStats" class="loading-cache">
      <p>{{ getMessage('loadingCacheInfoStatus') }}</p>
    </div>

    <!-- Progress Indicator -->
    <ProgressIndicator
      v-if="isManagingCache"
      :visible="isManagingCache"
      :text="isManagingCache ? getMessage('processingCacheStatus') : ''"
      :showSpinner="true"
    />

    <!-- Action Buttons -->
    <div class="cache-actions">
      <div class="secondary-button" :disabled="isManagingCache" @click="$emit('cleanup-cache')">
        <span class="stats-icon"><DatabaseIcon /></span>
        <span>{{
          isManagingCache ? getMessage('cleaningStatus') : getMessage('cleanExpiredCacheButton')
        }}</span>
      </div>

      <div class="danger-button" :disabled="isManagingCache" @click="$emit('clear-all-cache')">
        <span class="stats-icon"><TrashIcon /></span>
        <span>{{ isManagingCache ? getMessage('clearingStatus') : getMessage('clearAllCacheButton') }}</span>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import ProgressIndicator from './ProgressIndicator.vue';
import { DatabaseIcon, VectorIcon, TrashIcon } from './icons';
import { getMessage } from '@/utils/i18n';

interface CacheEntry {
  url: string;
  size: number;
  sizeMB: number;
  timestamp: number;
  age: string;
  expired: boolean;
}

interface CacheStats {
  totalSize: number;
  totalSizeMB: number;
  entryCount: number;
  entries: CacheEntry[];
}

interface Props {
  cacheStats: CacheStats | null;
  isManagingCache: boolean;
}

interface Emits {
  (e: 'cleanup-cache'): void;
  (e: 'clear-all-cache'): void;
}

defineProps<Props>();
defineEmits<Emits>();

const getModelNameFromUrl = (url: string) => {
  // Extract model name from HuggingFace URL
  const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)/);
  if (match) {
    return match[1];
  }
  return url.split('/').pop() || url;
};
</script>

<style scoped>
.model-cache-section {
  margin-bottom: 24px;
}

.section-title {
  font-size: 16px;
  font-weight: 600;
  color: #374151;
  margin-bottom: 12px;
}

.stats-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  margin-bottom: 16px;
}

.stats-card {
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
  padding: 16px;
}

.stats-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}

.stats-label {
  font-size: 14px;
  font-weight: 500;
  color: #64748b;
}

.stats-icon {
  padding: 8px;
  border-radius: 8px;
  width: 36px;
  height: 36px;
}

.stats-icon.orange {
  background: #fed7aa;
  color: #ea580c;
}

.stats-icon.purple {
  background: #e9d5ff;
  color: #9333ea;
}

.stats-value {
  font-size: 30px;
  font-weight: 700;
  color: #0f172a;
  margin: 0;
}

.cache-details {
  margin-bottom: 16px;
}

.cache-details-title {
  font-size: 14px;
  font-weight: 600;
  color: #374151;
  margin: 0 0 12px 0;
}

.cache-entries {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.cache-entry {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 12px;
}

.entry-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.entry-url {
  font-weight: 500;
  color: #1f2937;
  font-size: 14px;
}

.entry-details {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 12px;
}

.entry-size {
  background: #dbeafe;
  color: #1e40af;
  padding: 2px 6px;
  border-radius: 4px;
}

.entry-age {
  color: #6b7280;
}

.entry-expired {
  background: #fee2e2;
  color: #dc2626;
  padding: 2px 6px;
  border-radius: 4px;
}

.no-cache,
.loading-cache {
  text-align: center;
  color: #6b7280;
  padding: 20px;
  background: #f8fafc;
  border-radius: 8px;
  border: 1px solid #e2e8f0;
  margin-bottom: 16px;
}

.cache-actions {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.secondary-button {
  background: #f1f5f9;
  color: #475569;
  border: 1px solid #cbd5e1;
  padding: 8px 16px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  justify-content: center;
  user-select: none;
  cursor: pointer;
}

.secondary-button:hover:not(:disabled) {
  background: #e2e8f0;
  border-color: #94a3b8;
}

.secondary-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.danger-button {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  background: white;
  border: 1px solid #d1d5db;
  color: #374151;
  font-weight: 600;
  padding: 12px 16px;
  border-radius: 8px;
  cursor: pointer;
  user-select: none;
  transition: all 0.2s ease;
}

.danger-button:hover:not(:disabled) {
  border-color: #ef4444;
  color: #dc2626;
}

.danger-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/types.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Design Tokens Types (Phase 5.4)
 *
 * Type definitions for runtime CSS custom properties (design tokens).
 *
 * Scope:
 * - Phase 5.4: Runtime CSS variables only (no server-side token scanning)
 * - Future phases may extend to support project-level tokens from config files
 */

// =============================================================================
// Core Identifiers
// =============================================================================

/**
 * CSS custom property name (must start with `--`).
 * Example: '--color-primary', '--spacing-md'
 */
export type CssVarName = `--${string}`;

/**
 * Root key for caching token indices.
 * Uses Document or ShadowRoot as WeakMap keys.
 */
export type RootCacheKey = Document | ShadowRoot;

/** Type of root context */
export type RootType = 'document' | 'shadow';

// =============================================================================
// Token Classification
// =============================================================================

/**
 * Token value type classification.
 * Used for filtering and UI grouping.
 */
export type TokenKind =
  | 'color' // Color values (hex, rgb, hsl, etc.)
  | 'length' // Length values (px, rem, em, %, etc.)
  | 'number' // Unitless numbers
  | 'shadow' // Box/text shadow values
  | 'font' // Font family or font-related values
  | 'unknown'; // Unable to classify

// =============================================================================
// Declaration Source
// =============================================================================

/** Reference to a stylesheet */
export interface StyleSheetRef {
  /** Full URL if available */
  url?: string;
  /** Human-readable label (filename or element description) */
  label: string;
}

/** Where the token declaration originated */
export type TokenDeclarationOrigin = 'rule' | 'inline';

/**
 * A single declaration site for a token.
 * One token name can have multiple declarations across stylesheets/rules.
 */
export interface TokenDeclaration {
  /** Token name (e.g., '--color-primary') */
  name: CssVarName;
  /** Raw declared value */
  value: string;
  /** Whether declared with !important */
  important: boolean;
  /** Origin type */
  origin: TokenDeclarationOrigin;
  /** Root type where declared */
  rootType: RootType;
  /** Source stylesheet reference */
  styleSheet?: StyleSheetRef;
  /** CSS selector for rule-based declarations */
  selectorText?: string;
  /** Source order within collection pass (ascending) */
  order: number;
}

// =============================================================================
// Token Model
// =============================================================================

/**
 * Design token with all known declarations.
 * Aggregates declaration sites for a single token name.
 */
export interface DesignToken {
  /** Token name */
  name: CssVarName;
  /** Best-effort value type classification */
  kind: TokenKind;
  /** All declaration sites in source order */
  declarations: readonly TokenDeclaration[];
}

// =============================================================================
// Index and Query Results
// =============================================================================

/** Statistics from a token collection pass */
export interface TokenIndexStats {
  /** Number of stylesheets scanned */
  styleSheets: number;
  /** Number of CSS rules processed */
  rulesScanned: number;
  /** Number of unique token names found */
  tokens: number;
  /** Total number of declaration sites */
  declarations: number;
}

/**
 * Root-level token index.
 * Contains all token declarations found in a root's stylesheets.
 */
export interface TokenIndex {
  /** Root type */
  rootType: RootType;
  /** Map of token name to declaration sites */
  tokens: Map<CssVarName, TokenDeclaration[]>;
  /** Warnings encountered during scanning */
  warnings: string[];
  /** Collection statistics */
  stats: TokenIndexStats;
}

/**
 * Token with its computed value in a specific element context.
 * Used for showing available tokens when editing an element.
 */
export interface ContextToken {
  /** Token definition */
  token: DesignToken;
  /** Computed value via getComputedStyle(element).getPropertyValue(name) */
  computedValue: string;
}

/** Generic query result wrapper */
export interface TokenQueryResult<T> {
  /** Result items */
  tokens: readonly T[];
  /** Warnings from the operation */
  warnings: readonly string[];
  /** Statistics */
  stats: TokenIndexStats;
}

// =============================================================================
// Resolution Types
// =============================================================================

/** Parsed var() reference */
export interface CssVarReference {
  /** Token name */
  name: CssVarName;
  /** Optional fallback value */
  fallback?: string;
}

/** Token availability status */
export type TokenAvailability = 'available' | 'unset';

/** Method used to resolve token value */
export type TokenResolutionMethod =
  | 'computed' // getComputedStyle().getPropertyValue()
  | 'probe' // DOM probe element
  | 'none'; // Not resolved

/** Token resolution result */
export interface TokenResolution {
  /** Token name */
  token: CssVarName;
  /** Computed custom property value (may be empty if unset) */
  computedValue: string;
  /** Availability status */
  availability: TokenAvailability;
}

/** Resolved token ready to apply to a CSS property */
export interface TokenResolvedForProperty {
  /** Token name */
  token: CssVarName;
  /** Target CSS property */
  cssProperty: string;
  /** CSS value to apply (e.g., 'var(--token)' or 'var(--token, fallback)') */
  cssValue: string;
  /** Best-effort resolved preview value */
  resolvedValue?: string;
  /** Resolution method used */
  method: TokenResolutionMethod;
}

// =============================================================================
// Cache Invalidation
// =============================================================================

/** Reason for cache invalidation */
export type TokenInvalidationReason =
  | 'manual' // Explicitly invalidated via API
  | 'head_mutation' // Document head changed (style/link added/removed)
  | 'shadow_mutation' // ShadowRoot content changed
  | 'ttl' // Time-to-live expired
  | 'unknown';

/** Event emitted when token cache is invalidated */
export interface TokenInvalidationEvent {
  /** Affected root */
  root: RootCacheKey;
  /** Root type */
  rootType: RootType;
  /** Invalidation reason */
  reason: TokenInvalidationReason;
  /** Timestamp */
  timestamp: number;
}

/** Unsubscribe function for event listeners */
export type Unsubscribe = () => void;

```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/strategies/anchor-relpath.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Anchor + Relative Path Strategy
 *
 * This strategy generates selectors by finding a stable ancestor "anchor"
 * (element with unique id or data-testid/data-qa/etc.) and building a
 * relative path from that anchor to the target element.
 *
 * Use case: When the target element itself has no unique identifiers,
 * but a nearby ancestor does.
 *
 * Example output: '[data-testid="card"] div > span:nth-of-type(2) > button'
 * (anchor selector + descendant combinator + relative path with child combinators)
 */

import type { SelectorCandidate, SelectorStrategy, SelectorStrategyContext } from '../types';

// =============================================================================
// Constants
// =============================================================================

/** Maximum ancestor depth to search for an anchor */
const MAX_ANCHOR_DEPTH = 20;

/** Data attributes eligible for anchor selection (stable, test-friendly) */
const ANCHOR_DATA_ATTRS = [
  'data-testid',
  'data-test-id',
  'data-test',
  'data-qa',
  'data-cy',
] as const;

/**
 * Weight penalty for anchor-relpath candidates.
 * This ensures they rank lower than direct selectors (id, testid, class)
 * but higher than pure text selectors.
 */
const ANCHOR_RELPATH_WEIGHT = -10;

// =============================================================================
// Internal Helpers
// =============================================================================

function safeQuerySelector(root: ParentNode, selector: string): Element | null {
  try {
    return root.querySelector(selector);
  } catch {
    return null;
  }
}

/**
 * Get siblings from the appropriate parent context
 */
function getSiblings(element: Element): Element[] {
  const parent = element.parentElement;
  if (parent) {
    return Array.from(parent.children);
  }

  const parentNode = element.parentNode;
  if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {
    return Array.from(parentNode.children);
  }

  return [];
}

/**
 * Try to build a unique anchor selector for an element.
 * Only uses stable identifiers: id or ANCHOR_DATA_ATTRS.
 */
function tryAnchorSelector(element: Element, ctx: SelectorStrategyContext): string | null {
  const { helpers } = ctx;
  const tag = element.tagName.toLowerCase();

  // Try ID first (highest priority)
  const id = element.id?.trim();
  if (id) {
    const idSelector = `#${helpers.cssEscape(id)}`;
    if (helpers.isUnique(idSelector)) {
      return idSelector;
    }
  }

  // Try stable data attributes
  for (const attr of ANCHOR_DATA_ATTRS) {
    const value = element.getAttribute(attr)?.trim();
    if (!value) continue;

    const escaped = helpers.cssEscape(value);

    // Try attribute-only selector
    const attrOnly = `[${attr}="${escaped}"]`;
    if (helpers.isUnique(attrOnly)) {
      return attrOnly;
    }

    // Try with tag prefix for disambiguation
    const withTag = `${tag}${attrOnly}`;
    if (helpers.isUnique(withTag)) {
      return withTag;
    }
  }

  return null;
}

/**
 * Build a relative path selector from an ancestor to a target element.
 * Uses tag names with :nth-of-type() for disambiguation.
 *
 * @returns Selector string like "div > span:nth-of-type(2) > button", or null if failed
 */
function buildRelativePathSelector(
  ancestor: Element,
  target: Element,
  root: Document | ShadowRoot,
): string | null {
  const segments: string[] = [];
  let current: Element | null = target;

  for (let depth = 0; current && current !== ancestor && depth < MAX_ANCHOR_DEPTH; depth++) {
    const tag = current.tagName.toLowerCase();
    let segment = tag;

    // Calculate nth-of-type index if there are siblings with same tag
    const siblings = getSiblings(current);
    const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName);

    if (sameTagSiblings.length > 1) {
      const index = sameTagSiblings.indexOf(current) + 1;
      segment += `:nth-of-type(${index})`;
    }

    segments.unshift(segment);

    // Move to parent
    const parentEl: Element | null = current.parentElement;
    if (!parentEl) {
      // Check if we've reached the root boundary
      const parentNode = current.parentNode;
      if (parentNode === root) break;
      break;
    }

    current = parentEl;
  }

  // Verify we reached the ancestor
  if (current !== ancestor) {
    return null;
  }

  return segments.length > 0 ? segments.join(' > ') : null;
}

/**
 * Build an "anchor + relative path" selector for an element.
 *
 * Algorithm:
 * 1. Walk up from target's parent, looking for an anchor
 * 2. For each potential anchor, build the relative path
 * 3. Verify the composed selector uniquely matches the target
 */
function buildAnchorRelPathSelector(element: Element, ctx: SelectorStrategyContext): string | null {
  const { root } = ctx;

  // Ensure root is a valid query context
  if (!(root instanceof Document || root instanceof ShadowRoot)) {
    return null;
  }

  let current: Element | null = element.parentElement;

  for (let depth = 0; current && depth < MAX_ANCHOR_DEPTH; depth++) {
    // Skip document root elements
    const tagUpper = current.tagName.toUpperCase();
    if (tagUpper === 'HTML' || tagUpper === 'BODY') {
      break;
    }

    // Try to use this element as an anchor
    const anchor = tryAnchorSelector(current, ctx);
    if (!anchor) {
      current = current.parentElement;
      continue;
    }

    // Build relative path from anchor to target
    const relativePath = buildRelativePathSelector(current, element, root);
    if (!relativePath) {
      current = current.parentElement;
      continue;
    }

    // Compose the full selector
    const composed = `${anchor} ${relativePath}`;

    // Verify uniqueness
    if (!ctx.helpers.isUnique(composed)) {
      current = current.parentElement;
      continue;
    }

    // Final verification: ensure we match the exact element
    const found = safeQuerySelector(root, composed);
    if (found === element) {
      return composed;
    }

    current = current.parentElement;
  }

  return null;
}

// =============================================================================
// Strategy Export
// =============================================================================

export const anchorRelpathStrategy: SelectorStrategy = {
  id: 'anchor-relpath',

  generate(ctx: SelectorStrategyContext): ReadonlyArray<SelectorCandidate> {
    const selector = buildAnchorRelPathSelector(ctx.element, ctx);

    if (!selector) {
      return [];
    }

    return [
      {
        type: 'css',
        value: selector,
        weight: ANCHOR_RELPATH_WEIGHT,
        source: 'generated',
        strategy: 'anchor-relpath',
      },
    ];
  },
};

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Quick Panel Tabs Handler
 *
 * Background service worker bridge for Quick Panel (content script) to:
 * - Enumerate tabs for search suggestions
 * - Activate a selected tab
 * - Close a tab
 *
 * Note: Content scripts cannot access chrome.tabs.* directly.
 */

import {
  BACKGROUND_MESSAGE_TYPES,
  type QuickPanelActivateTabMessage,
  type QuickPanelActivateTabResponse,
  type QuickPanelCloseTabMessage,
  type QuickPanelCloseTabResponse,
  type QuickPanelTabSummary,
  type QuickPanelTabsQueryMessage,
  type QuickPanelTabsQueryResponse,
} from '@/common/message-types';

// ============================================================
// Constants
// ============================================================

const LOG_PREFIX = '[QuickPanelTabs]';

// ============================================================
// Helpers
// ============================================================

function isValidTabId(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value) && value > 0;
}

function isValidWindowId(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value) && value > 0;
}

function normalizeBoolean(value: unknown): boolean {
  return value === true;
}

function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
  const anyTab = tab as unknown as { lastAccessed?: unknown };
  const value = anyTab.lastAccessed;
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

function safeErrorMessage(err: unknown): string {
  if (err instanceof Error) {
    return err.message || String(err);
  }
  return String(err);
}

/**
 * Convert a chrome.tabs.Tab to our summary format.
 * Returns null if tab is invalid.
 */
function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {
  if (!isValidTabId(tab.id)) return null;

  const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;
  if (windowId === null) return null;

  return {
    tabId: tab.id,
    windowId,
    title: tab.title ?? '',
    url: tab.url ?? '',
    favIconUrl: tab.favIconUrl ?? undefined,
    active: normalizeBoolean(tab.active),
    pinned: normalizeBoolean(tab.pinned),
    audible: normalizeBoolean(tab.audible),
    muted: normalizeBoolean(tab.mutedInfo?.muted),
    index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,
    lastAccessed: getLastAccessed(tab),
  };
}

// ============================================================
// Message Handlers
// ============================================================

async function handleTabsQuery(
  message: QuickPanelTabsQueryMessage,
  sender: chrome.runtime.MessageSender,
): Promise<QuickPanelTabsQueryResponse> {
  try {
    const includeAllWindows = message.payload?.includeAllWindows ?? true;

    // Extract current context from sender
    const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;
    const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;

    // Quick Panel should only be called from content scripts (which have sender.tab)
    // Reject requests without valid sender tab context for security
    if (!includeAllWindows && currentWindowId === null) {
      return {
        success: false,
        error: 'Invalid request: sender tab context required for window-scoped queries',
      };
    }

    // Build query info based on scope
    const queryInfo: chrome.tabs.QueryInfo = includeAllWindows
      ? {}
      : { windowId: currentWindowId! };

    const tabs = await chrome.tabs.query(queryInfo);

    // Convert to summaries, filtering out invalid tabs
    const summaries: QuickPanelTabSummary[] = [];
    for (const tab of tabs) {
      const summary = toTabSummary(tab);
      if (summary) {
        summaries.push(summary);
      }
    }

    return {
      success: true,
      tabs: summaries,
      currentTabId,
      currentWindowId,
    };
  } catch (err) {
    console.warn(`${LOG_PREFIX} Error querying tabs:`, err);
    return {
      success: false,
      error: safeErrorMessage(err) || 'Failed to query tabs',
    };
  }
}

async function handleActivateTab(
  message: QuickPanelActivateTabMessage,
): Promise<QuickPanelActivateTabResponse> {
  try {
    const tabId = message.payload?.tabId;
    const windowId = message.payload?.windowId;

    if (!isValidTabId(tabId)) {
      return { success: false, error: 'Invalid tabId' };
    }

    // Focus the window first if provided
    if (isValidWindowId(windowId)) {
      try {
        await chrome.windows.update(windowId, { focused: true });
      } catch {
        // Best-effort: tab activation may still succeed without focusing window.
      }
    }

    // Activate the tab
    await chrome.tabs.update(tabId, { active: true });

    return { success: true };
  } catch (err) {
    console.warn(`${LOG_PREFIX} Error activating tab:`, err);
    return {
      success: false,
      error: safeErrorMessage(err) || 'Failed to activate tab',
    };
  }
}

async function handleCloseTab(
  message: QuickPanelCloseTabMessage,
): Promise<QuickPanelCloseTabResponse> {
  try {
    const tabId = message.payload?.tabId;

    if (!isValidTabId(tabId)) {
      return { success: false, error: 'Invalid tabId' };
    }

    await chrome.tabs.remove(tabId);

    return { success: true };
  } catch (err) {
    console.warn(`${LOG_PREFIX} Error closing tab:`, err);
    return {
      success: false,
      error: safeErrorMessage(err) || 'Failed to close tab',
    };
  }
}

// ============================================================
// Initialization
// ============================================================

let initialized = false;

/**
 * Initialize the Quick Panel Tabs handler.
 * Safe to call multiple times - subsequent calls are no-ops.
 */
export function initQuickPanelTabsHandler(): void {
  if (initialized) return;
  initialized = true;

  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    // Tabs query
    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {
      handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);
      return true; // Will respond asynchronously
    }

    // Tab activate
    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {
      handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);
      return true;
    }

    // Tab close
    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {
      handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);
      return true;
    }

    return false; // Not handled by this listener
  });

  console.debug(`${LOG_PREFIX} Initialized`);
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/types.ts:
--------------------------------------------------------------------------------

```typescript
/**
 * Shared selector engine types.
 *
 * Goals:
 * - JSON-serializable (store in flows / send across message boundary)
 * - Reusable from both content scripts and background
 *
 * Composite selector format:
 *   "<frameSelector> |> <innerSelector>"
 * This is kept for backward compatibility with the existing recorder and
 * accessibility-tree helper.
 */

export type NonEmptyArray<T> = [T, ...T[]];

export interface Point {
  x: number;
  y: number;
}

export type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';
export type SelectorCandidateSource = 'recorded' | 'user' | 'generated';

export interface SelectorStabilitySignals {
  usesId?: boolean;
  usesTestId?: boolean;
  usesAria?: boolean;
  usesText?: boolean;
  usesNthOfType?: boolean;
  usesAttributes?: boolean;
  usesClass?: boolean;
}

export interface SelectorStability {
  /** Stability score in range [0, 1]. Higher is more stable. */
  score: number;
  signals?: SelectorStabilitySignals;
  note?: string;
}

export interface SelectorCandidateBase {
  type: SelectorType;
  /**
   * Primary representation:
   * - css/attr: CSS selector string
   * - xpath: XPath expression string
   * - text: visible text query string
   * - aria: human-readable expression for debugging/UI
   */
  value: string;
  /** Optional user-adjustable priority. Higher wins when ordering candidates. */
  weight?: number;
  /** Where this candidate came from. */
  source?: SelectorCandidateSource;
  /** Strategy identifier that produced this candidate. */
  strategy?: string;
  /** Optional computed stability. */
  stability?: SelectorStability;
}

export type TextMatchMode = 'exact' | 'contains';

export type SelectorCandidate =
  | (SelectorCandidateBase & { type: 'css' | 'attr' })
  | (SelectorCandidateBase & { type: 'xpath' })
  | (SelectorCandidateBase & { type: 'text'; match?: TextMatchMode; tagNameHint?: string })
  | (SelectorCandidateBase & { type: 'aria'; role?: string; name?: string });

export interface SelectorTarget {
  /**
   * Optional primary selector string.
   * This is the fast path for locating (usually CSS). May be composite.
   */
  selector?: string;
  /** Ordered candidates; must be non-empty. */
  candidates: NonEmptyArray<SelectorCandidate>;
  /** Optional tag name hint used for text search. */
  tagName?: string;
  /** Optional ephemeral element ref, when available. */
  ref?: string;

  // --------------------------------
  // Extended Locator Metadata (Phase 1.2)
  // --------------------------------
  // These fields are generated and carried across message/storage boundaries,
  // but the background-side SelectorLocator may not fully use them until
  // Phase 2 wires the DOM-side protocol (fingerprint verification, shadow traversal).

  /**
   * Structural fingerprint for fuzzy element matching.
   * Format: "tag|id=xxx|class=a.b.c|text=xxx"
   */
  fingerprint?: string;

  /**
   * Child-index path relative to the current root (Document/ShadowRoot).
   * Used for fast element recovery when selectors fail.
   */
  domPath?: number[];

  /**
   * Shadow host selector chain (outer -> inner).
   * When present, selectors/domPath are relative to the innermost ShadowRoot.
   */
  shadowHostChain?: string[];
}

/**
 * SelectorTarget with required extended locator metadata.
 *
 * Use this type when all extended fields must be present (e.g., for reliable
 * cross-session persistence or HMR recovery).
 *
 * Note: Phase 1.2 only guarantees generation/transport; behavioral enforcement
 * (fingerprint verification, shadow traversal) depends on Phase 2 integration.
 */
export interface ExtendedSelectorTarget extends SelectorTarget {
  fingerprint: string;
  domPath: number[];
  /** May be empty array if element is not inside Shadow DOM */
  shadowHostChain: string[];
}

export interface LocatedElement {
  ref: string;
  center: Point;
  /** Resolved frameId in the tab (when inside an iframe). */
  frameId?: number;
  resolvedBy: 'ref' | SelectorType;
  selectorUsed?: string;
}

export interface SelectorLocateOptions {
  /** Frame context for non-composite selectors (default: top frame). */
  frameId?: number;
  /** Whether to try resolving `target.ref` before selectors. */
  preferRef?: boolean;
  /** Forwarded to helper uniqueness checks. */
  allowMultiple?: boolean;
  /**
   * Whether to verify target.fingerprint when available.
   *
   * Note: Phase 1.2 exposes this option but may not fully enforce it until
   * the DOM-side protocol is wired (Phase 2).
   */
  verifyFingerprint?: boolean;
}

// ================================
// Composite Selector Utilities
// ================================

export const COMPOSITE_SELECTOR_SEPARATOR = '|>' as const;

export interface CompositeSelectorParts {
  frameSelector: string;
  innerSelector: string;
}

export function splitCompositeSelector(selector: string): CompositeSelectorParts | null {
  if (typeof selector !== 'string') return null;

  const parts = selector
    .split(COMPOSITE_SELECTOR_SEPARATOR)
    .map((s) => s.trim())
    .filter(Boolean);

  if (parts.length < 2) return null;

  return {
    frameSelector: parts[0],
    innerSelector: parts.slice(1).join(` ${COMPOSITE_SELECTOR_SEPARATOR} `),
  };
}

export function isCompositeSelector(selector: string): boolean {
  return splitCompositeSelector(selector) !== null;
}

export function composeCompositeSelector(frameSelector: string, innerSelector: string): string {
  return `${String(frameSelector).trim()} ${COMPOSITE_SELECTOR_SEPARATOR} ${String(innerSelector).trim()}`.trim();
}

// ================================
// Strategy Pattern Types
// ================================

export interface NormalizedSelectorGenerationOptions {
  maxCandidates: number;
  includeText: boolean;
  includeAria: boolean;
  includeCssUnique: boolean;
  includeCssPath: boolean;
  testIdAttributes: ReadonlyArray<string>;
  textMaxLength: number;
  textTags: ReadonlyArray<string>;
}

export interface SelectorGenerationOptions {
  maxCandidates?: number;
  includeText?: boolean;
  includeAria?: boolean;
  includeCssUnique?: boolean;
  includeCssPath?: boolean;
  testIdAttributes?: ReadonlyArray<string>;
  textMaxLength?: number;
  textTags?: ReadonlyArray<string>;
}

export interface SelectorStrategyHelpers {
  cssEscape: (value: string) => string;
  isUnique: (selector: string) => boolean;
  safeQueryAll: (selector: string) => ReadonlyArray<Element>;
}

export interface SelectorStrategyContext {
  element: Element;
  root: ParentNode;
  options: NormalizedSelectorGenerationOptions;
  helpers: SelectorStrategyHelpers;
}

export interface SelectorStrategy {
  /** Stable id used for debugging/analytics. */
  id: string;
  generate: (ctx: SelectorStrategyContext) => ReadonlyArray<SelectorCandidate>;
}

```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts:
--------------------------------------------------------------------------------

```typescript
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
import { TargetLocator, SelectorCandidate } from './types';

// design note: minimal selector engine that tries ref then candidates

export interface LocatedElement {
  ref?: string;
  center?: { x: number; y: number };
  resolvedBy?: 'ref' | SelectorCandidate['type'];
  frameId?: number;
}

// Helper: decide whether selector is a composite cross-frame selector
function isCompositeSelector(sel: string): boolean {
  return typeof sel === 'string' && sel.includes('|>');
}

// Helper: typed wrapper for chrome.tabs.sendMessage with optional frameId
async function sendToTab(tabId: number, message: any, frameId?: number): Promise<any> {
  if (typeof frameId === 'number') {
    return await chrome.tabs.sendMessage(tabId, message, { frameId });
  }
  return await chrome.tabs.sendMessage(tabId, message);
}

// Helper: ensure ref for a selector, handling composite selectors and mapping frameId
async function ensureRefForSelector(
  tabId: number,
  selector: string,
  frameId?: number,
): Promise<{ ref: string; center: { x: number; y: number }; frameId?: number } | null> {
  try {
    let ensured: any = null;
    if (isCompositeSelector(selector)) {
      // Always query top for composite; helper will bridge to child and return href
      ensured = await sendToTab(tabId, {
        action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
        selector,
      });
    } else {
      ensured = await sendToTab(
        tabId,
        { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector },
        frameId,
      );
    }
    if (!ensured || !ensured.success || !ensured.ref || !ensured.center) return null;
    // Map frameId when composite via returned href
    let locFrameId: number | undefined = undefined;
    if (isCompositeSelector(selector) && ensured.href) {
      try {
        const frames = (await chrome.webNavigation.getAllFrames({ tabId })) as any[];
        const match = frames?.find((f) => typeof f.url === 'string' && f.url === ensured.href);
        if (match) locFrameId = match.frameId;
      } catch {}
    }
    return { ref: ensured.ref, center: ensured.center, frameId: locFrameId };
  } catch {
    return null;
  }
}

/**
 * Try to resolve an element using ref or candidates via content scripts
 */
export async function locateElement(
  tabId: number,
  target: TargetLocator,
  frameId?: number,
): Promise<LocatedElement | null> {
  // 0) Fast path: try primary selector if provided
  const primarySel = (target as any)?.selector ? String((target as any).selector).trim() : '';
  if (primarySel) {
    const ensured = await ensureRefForSelector(tabId, primarySel, frameId);
    if (ensured) return { ...ensured, resolvedBy: 'css' };
  }

  // 1) Non-text candidates first for stability (css/attr/aria/xpath)
  const nonText = (target.candidates || []).filter((c) => c.type !== 'text');
  for (const c of nonText) {
    try {
      if (c.type === 'css' || c.type === 'attr') {
        const ensured = await ensureRefForSelector(tabId, String(c.value || ''), frameId);
        if (ensured) return { ...ensured, resolvedBy: c.type };
      } else if (c.type === 'aria') {
        // Minimal ARIA role+name parser like: "button[name=提交]" or "textbox[name=用户名]"
        const v = String(c.value || '').trim();
        const m = v.match(/^(\w+)\s*\[\s*name\s*=\s*([^\]]+)\]$/);
        const role = m ? m[1] : '';
        const name = m ? m[2] : '';
        const cleanName = name.replace(/^['"]|['"]$/g, '');
        const ariaSelectors: string[] = [];
        if (role === 'textbox') {
          ariaSelectors.push(
            `[role="textbox"][aria-label=${JSON.stringify(cleanName)}]`,
            `input[aria-label=${JSON.stringify(cleanName)}]`,
            `textarea[aria-label=${JSON.stringify(cleanName)}]`,
          );
        } else if (role === 'button') {
          ariaSelectors.push(
            `[role="button"][aria-label=${JSON.stringify(cleanName)}]`,
            `button[aria-label=${JSON.stringify(cleanName)}]`,
          );
        } else if (role === 'link') {
          ariaSelectors.push(
            `[role="link"][aria-label=${JSON.stringify(cleanName)}]`,
            `a[aria-label=${JSON.stringify(cleanName)}]`,
          );
        }
        if (!ariaSelectors.length && role) {
          ariaSelectors.push(
            `[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`,
          );
        }
        for (const sel of ariaSelectors) {
          const ensured = await sendToTab(
            tabId,
            { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector: sel } as any,
            frameId,
          );
          if (ensured && ensured.success && ensured.ref && ensured.center) {
            return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };
          }
        }
      } else if (c.type === 'xpath') {
        // Minimal xpath support via document.evaluate through injected helper
        const ensured = await sendToTab(
          tabId,
          {
            action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
            selector: c.value,
            isXPath: true,
          } as any,
          frameId,
        );
        if (ensured && ensured.success && ensured.ref && ensured.center) {
          return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };
        }
      }
    } catch (e) {
      // continue to next candidate
    }
  }
  // 2) Human-intent fallback: text-based search as last resort
  const textCands = (target.candidates || []).filter((c) => c.type === 'text');
  const tagName = ((target as any)?.tag || '').toString();
  for (const c of textCands) {
    try {
      const ensured = await sendToTab(
        tabId,
        {
          action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
          useText: true,
          text: c.value,
          tagName,
        } as any,
        frameId,
      );
      if (ensured && ensured.success && ensured.ref && ensured.center) {
        return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };
      }
    } catch {}
  }
  // Fallback: try ref (works when ref was produced in the same page lifecycle)
  if (target.ref) {
    try {
      const res = await sendToTab(
        tabId,
        { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: target.ref } as any,
        frameId,
      );
      if (res && res.success && res.center) {
        return { ref: target.ref, center: res.center, resolvedBy: 'ref' };
      }
    } catch (e) {
      // ignore
    }
  }
  return null;
}

/**
 * Ensure screenshot context hostname is still valid for coordinate-based actions
 */
// Note: screenshot hostname validation is handled elsewhere; removed legacy stub.

```
Page 5/43FirstPrevNextLast