#
tokens: 49042/50000 26/574 files (page 4/60)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 4 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&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/native-server/src/mcp/mcp-server-stdio.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
  4 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
  5 | import {
  6 |   CallToolRequestSchema,
  7 |   CallToolResult,
  8 |   ListToolsRequestSchema,
  9 |   ListResourcesRequestSchema,
 10 |   ListPromptsRequestSchema,
 11 | } from '@modelcontextprotocol/sdk/types.js';
 12 | import { TOOL_SCHEMAS } from 'chrome-mcp-shared';
 13 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
 14 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
 15 | import * as fs from 'fs';
 16 | import * as path from 'path';
 17 | 
 18 | let stdioMcpServer: Server | null = null;
 19 | let mcpClient: Client | null = null;
 20 | 
 21 | // Read configuration from stdio-config.json
 22 | const loadConfig = () => {
 23 |   try {
 24 |     const configPath = path.join(__dirname, 'stdio-config.json');
 25 |     const configData = fs.readFileSync(configPath, 'utf8');
 26 |     return JSON.parse(configData);
 27 |   } catch (error) {
 28 |     console.error('Failed to load stdio-config.json:', error);
 29 |     throw new Error('Configuration file stdio-config.json not found or invalid');
 30 |   }
 31 | };
 32 | 
 33 | export const getStdioMcpServer = () => {
 34 |   if (stdioMcpServer) {
 35 |     return stdioMcpServer;
 36 |   }
 37 |   stdioMcpServer = new Server(
 38 |     {
 39 |       name: 'StdioChromeMcpServer',
 40 |       version: '1.0.0',
 41 |     },
 42 |     {
 43 |       capabilities: {
 44 |         tools: {},
 45 |         resources: {},
 46 |         prompts: {},
 47 |       },
 48 |     },
 49 |   );
 50 | 
 51 |   setupTools(stdioMcpServer);
 52 |   return stdioMcpServer;
 53 | };
 54 | 
 55 | export const ensureMcpClient = async () => {
 56 |   try {
 57 |     if (mcpClient) {
 58 |       const pingResult = await mcpClient.ping();
 59 |       if (pingResult) {
 60 |         return mcpClient;
 61 |       }
 62 |     }
 63 | 
 64 |     const config = loadConfig();
 65 |     mcpClient = new Client({ name: 'Mcp Chrome Proxy', version: '1.0.0' }, { capabilities: {} });
 66 |     const transport = new StreamableHTTPClientTransport(new URL(config.url), {});
 67 |     await mcpClient.connect(transport);
 68 |     return mcpClient;
 69 |   } catch (error) {
 70 |     mcpClient?.close();
 71 |     mcpClient = null;
 72 |     console.error('Failed to connect to MCP server:', error);
 73 |   }
 74 | };
 75 | 
 76 | export const setupTools = (server: Server) => {
 77 |   // List tools handler
 78 |   server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
 79 | 
 80 |   // Call tool handler
 81 |   server.setRequestHandler(CallToolRequestSchema, async (request) =>
 82 |     handleToolCall(request.params.name, request.params.arguments || {}),
 83 |   );
 84 | 
 85 |   // List resources handler - REQUIRED BY MCP PROTOCOL
 86 |   server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
 87 | 
 88 |   // List prompts handler - REQUIRED BY MCP PROTOCOL
 89 |   server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
 90 | };
 91 | 
 92 | const handleToolCall = async (name: string, args: any): Promise<CallToolResult> => {
 93 |   try {
 94 |     const client = await ensureMcpClient();
 95 |     if (!client) {
 96 |       throw new Error('Failed to connect to MCP server');
 97 |     }
 98 |     // Use a sane default of 2 minutes; the previous value mistakenly used 2*6*1000 (12s)
 99 |     const DEFAULT_CALL_TIMEOUT_MS = 2 * 60 * 1000;
100 |     const result = await client.callTool({ name, arguments: args }, undefined, {
101 |       timeout: DEFAULT_CALL_TIMEOUT_MS,
102 |     });
103 |     return result as CallToolResult;
104 |   } catch (error: any) {
105 |     return {
106 |       content: [
107 |         {
108 |           type: 'text',
109 |           text: `Error calling tool: ${error.message}`,
110 |         },
111 |       ],
112 |       isError: true,
113 |     };
114 |   }
115 | };
116 | 
117 | async function main() {
118 |   const transport = new StdioServerTransport();
119 |   await getStdioMcpServer().connect(transport);
120 | }
121 | 
122 | main().catch((error) => {
123 |   console.error('Fatal error Chrome MCP Server main():', error);
124 |   process.exit(1);
125 | });
126 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import type { ExecCtx, ExecResult, NodeRuntime } from './types';
 2 | 
 3 | export const executeFlowNode: NodeRuntime<any> = {
 4 |   validate: (step) => {
 5 |     const s: any = step;
 6 |     const ok = typeof s.flowId === 'string' && !!s.flowId;
 7 |     return ok ? { ok } : { ok, errors: ['需提供 flowId'] };
 8 |   },
 9 |   run: async (ctx: ExecCtx, step) => {
10 |     const s: any = step;
11 |     const { getFlow } = await import('../flow-store');
12 |     const flow = await getFlow(String(s.flowId));
13 |     if (!flow) throw new Error('referenced flow not found');
14 |     const inline = s.inline !== false; // default inline
15 |     if (!inline) {
16 |       const { runFlow } = await import('../flow-runner');
17 |       await runFlow(flow, { args: s.args || {}, returnLogs: false });
18 |       return {} as ExecResult;
19 |     }
20 |     const { defaultEdgesOnly, topoOrder, mapDagNodeToStep, waitForNetworkIdle, waitForNavigation } =
21 |       await import('../rr-utils');
22 |     const vars = ctx.vars;
23 |     if (s.args && typeof s.args === 'object') Object.assign(vars, s.args);
24 | 
25 |     // DAG is required - flow-store guarantees nodes/edges via normalization
26 |     const nodes = ((flow as any).nodes || []) as any[];
27 |     const edges = ((flow as any).edges || []) as any[];
28 |     if (nodes.length === 0) {
29 |       throw new Error(
30 |         'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.',
31 |       );
32 |     }
33 |     const defaultEdges = defaultEdgesOnly(edges as any);
34 |     const order = topoOrder(nodes as any, defaultEdges as any);
35 |     const stepsToRun: any[] = order.map((n) => mapDagNodeToStep(n as any));
36 |     for (const st of stepsToRun) {
37 |       const t0 = Date.now();
38 |       const maxRetries = Math.max(0, (st as any).retry?.count ?? 0);
39 |       const baseInterval = Math.max(0, (st as any).retry?.intervalMs ?? 0);
40 |       let attempt = 0;
41 |       const doDelay = async (i: number) => {
42 |         const delay =
43 |           baseInterval > 0
44 |             ? (st as any).retry?.backoff === 'exp'
45 |               ? baseInterval * Math.pow(2, i)
46 |               : baseInterval
47 |             : 0;
48 |         if (delay > 0) await new Promise((r) => setTimeout(r, delay));
49 |       };
50 |       while (true) {
51 |         try {
52 |           const beforeInfo = await (async () => {
53 |             const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
54 |             const tab = tabs[0];
55 |             return { url: tab?.url || '', status: (tab as any)?.status || '' };
56 |           })();
57 |           const { executeStep } = await import('../nodes');
58 |           const result = await executeStep(ctx as any, st as any);
59 |           if ((st.type === 'click' || st.type === 'dblclick') && (st as any).after) {
60 |             const after = (st as any).after as any;
61 |             if (after.waitForNavigation)
62 |               await waitForNavigation((st as any).timeoutMs, beforeInfo.url);
63 |             else if (after.waitForNetworkIdle)
64 |               await waitForNetworkIdle(Math.min((st as any).timeoutMs || 5000, 120000), 1200);
65 |           }
66 |           if (!result?.alreadyLogged)
67 |             ctx.logger({ stepId: st.id, status: 'success', tookMs: Date.now() - t0 } as any);
68 |           break;
69 |         } catch (e: any) {
70 |           if (attempt < maxRetries) {
71 |             ctx.logger({
72 |               stepId: st.id,
73 |               status: 'retrying',
74 |               message: e?.message || String(e),
75 |             } as any);
76 |             await doDelay(attempt);
77 |             attempt += 1;
78 |             continue;
79 |           }
80 |           ctx.logger({
81 |             stepId: st.id,
82 |             status: 'failed',
83 |             message: e?.message || String(e),
84 |             tookMs: Date.now() - t0,
85 |           } as any);
86 |           throw e;
87 |         }
88 |       }
89 |     }
90 |     return {} as ExecResult;
91 |   },
92 | };
93 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Disposables Utility
  3 |  *
  4 |  * Provides deterministic cleanup for event listeners, observers, and other resources.
  5 |  * Ensures proper cleanup order (LIFO) and prevents memory leaks.
  6 |  */
  7 | 
  8 | /** Function that performs cleanup */
  9 | export type DisposeFn = () => void;
 10 | 
 11 | /**
 12 |  * Manages a collection of disposable resources.
 13 |  * Resources are disposed in reverse order (LIFO).
 14 |  */
 15 | export class Disposer {
 16 |   private disposed = false;
 17 |   private readonly disposers: DisposeFn[] = [];
 18 | 
 19 |   /** Whether this disposer has already been disposed */
 20 |   get isDisposed(): boolean {
 21 |     return this.disposed;
 22 |   }
 23 | 
 24 |   /**
 25 |    * Add a dispose function to be called during cleanup.
 26 |    * If already disposed, the function is called immediately.
 27 |    */
 28 |   add(dispose: DisposeFn): void {
 29 |     if (this.disposed) {
 30 |       try {
 31 |         dispose();
 32 |       } catch {
 33 |         // Best-effort cleanup for late additions
 34 |       }
 35 |       return;
 36 |     }
 37 |     this.disposers.push(dispose);
 38 |   }
 39 | 
 40 |   /**
 41 |    * Add an event listener and automatically remove it on dispose.
 42 |    */
 43 |   listen<K extends keyof WindowEventMap>(
 44 |     target: Window,
 45 |     type: K,
 46 |     listener: (ev: WindowEventMap[K]) => void,
 47 |     options?: boolean | AddEventListenerOptions,
 48 |   ): void;
 49 |   listen<K extends keyof DocumentEventMap>(
 50 |     target: Document,
 51 |     type: K,
 52 |     listener: (ev: DocumentEventMap[K]) => void,
 53 |     options?: boolean | AddEventListenerOptions,
 54 |   ): void;
 55 |   listen<K extends keyof HTMLElementEventMap>(
 56 |     target: HTMLElement,
 57 |     type: K,
 58 |     listener: (ev: HTMLElementEventMap[K]) => void,
 59 |     options?: boolean | AddEventListenerOptions,
 60 |   ): void;
 61 |   listen(
 62 |     target: EventTarget,
 63 |     type: string,
 64 |     listener: EventListenerOrEventListenerObject,
 65 |     options?: boolean | AddEventListenerOptions,
 66 |   ): void;
 67 |   listen(
 68 |     target: EventTarget,
 69 |     type: string,
 70 |     listener: EventListenerOrEventListenerObject,
 71 |     options?: boolean | AddEventListenerOptions,
 72 |   ): void {
 73 |     target.addEventListener(type, listener, options);
 74 |     this.add(() => target.removeEventListener(type, listener, options));
 75 |   }
 76 | 
 77 |   /**
 78 |    * Add a ResizeObserver and automatically disconnect it on dispose.
 79 |    */
 80 |   observeResize(
 81 |     target: Element,
 82 |     callback: ResizeObserverCallback,
 83 |     options?: ResizeObserverOptions,
 84 |   ): ResizeObserver {
 85 |     const observer = new ResizeObserver(callback);
 86 |     observer.observe(target, options);
 87 |     this.add(() => observer.disconnect());
 88 |     return observer;
 89 |   }
 90 | 
 91 |   /**
 92 |    * Add a MutationObserver and automatically disconnect it on dispose.
 93 |    */
 94 |   observeMutation(
 95 |     target: Node,
 96 |     callback: MutationCallback,
 97 |     options?: MutationObserverInit,
 98 |   ): MutationObserver {
 99 |     const observer = new MutationObserver(callback);
100 |     observer.observe(target, options);
101 |     this.add(() => observer.disconnect());
102 |     return observer;
103 |   }
104 | 
105 |   /**
106 |    * Add a requestAnimationFrame and automatically cancel it on dispose.
107 |    * Returns a function to manually cancel the frame.
108 |    */
109 |   requestAnimationFrame(callback: FrameRequestCallback): () => void {
110 |     const id = requestAnimationFrame(callback);
111 |     let cancelled = false;
112 | 
113 |     const cancel = () => {
114 |       if (cancelled) return;
115 |       cancelled = true;
116 |       cancelAnimationFrame(id);
117 |     };
118 | 
119 |     this.add(cancel);
120 |     return cancel;
121 |   }
122 | 
123 |   /**
124 |    * Dispose all registered resources in reverse order.
125 |    * Safe to call multiple times.
126 |    */
127 |   dispose(): void {
128 |     if (this.disposed) return;
129 |     this.disposed = true;
130 | 
131 |     // Dispose in reverse order (LIFO)
132 |     for (let i = this.disposers.length - 1; i >= 0; i--) {
133 |       try {
134 |         this.disposers[i]();
135 |       } catch {
136 |         // Best-effort cleanup, continue with remaining disposers
137 |       }
138 |     }
139 | 
140 |     this.disposers.length = 0;
141 |   }
142 | }
143 | 
```

--------------------------------------------------------------------------------
/packages/shared/src/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | export enum NativeMessageType {
  2 |   START = 'start',
  3 |   STARTED = 'started',
  4 |   STOP = 'stop',
  5 |   STOPPED = 'stopped',
  6 |   PING = 'ping',
  7 |   PONG = 'pong',
  8 |   ERROR = 'error',
  9 |   PROCESS_DATA = 'process_data',
 10 |   PROCESS_DATA_RESPONSE = 'process_data_response',
 11 |   CALL_TOOL = 'call_tool',
 12 |   CALL_TOOL_RESPONSE = 'call_tool_response',
 13 |   // Additional message types used in Chrome extension
 14 |   SERVER_STARTED = 'server_started',
 15 |   SERVER_STOPPED = 'server_stopped',
 16 |   ERROR_FROM_NATIVE_HOST = 'error_from_native_host',
 17 |   CONNECT_NATIVE = 'connectNative',
 18 |   ENSURE_NATIVE = 'ensure_native',
 19 |   PING_NATIVE = 'ping_native',
 20 |   DISCONNECT_NATIVE = 'disconnect_native',
 21 | }
 22 | 
 23 | export interface NativeMessage<P = any, E = any> {
 24 |   type?: NativeMessageType;
 25 |   responseToRequestId?: string;
 26 |   payload?: P;
 27 |   error?: E;
 28 | }
 29 | 
 30 | // ============================================================
 31 | // Element Picker Types (chrome_request_element_selection)
 32 | // ============================================================
 33 | 
 34 | /**
 35 |  * A single element selection request from the AI.
 36 |  */
 37 | export interface ElementPickerRequest {
 38 |   /**
 39 |    * Optional stable request id. If omitted, the extension will generate one.
 40 |    */
 41 |   id?: string;
 42 |   /**
 43 |    * Short label shown to the user (e.g., "Login button").
 44 |    */
 45 |   name: string;
 46 |   /**
 47 |    * Optional longer instruction shown to the user.
 48 |    */
 49 |   description?: string;
 50 | }
 51 | 
 52 | /**
 53 |  * Bounding rectangle of a picked element.
 54 |  */
 55 | export interface PickedElementRect {
 56 |   x: number;
 57 |   y: number;
 58 |   width: number;
 59 |   height: number;
 60 | }
 61 | 
 62 | /**
 63 |  * Center point of a picked element.
 64 |  */
 65 | export interface PickedElementPoint {
 66 |   x: number;
 67 |   y: number;
 68 | }
 69 | 
 70 | /**
 71 |  * A picked element that can be used with other tools (click, fill, etc.).
 72 |  */
 73 | export interface PickedElement {
 74 |   /**
 75 |    * Element ref written into window.__claudeElementMap (frame-local).
 76 |    * Can be used directly with chrome_click_element, chrome_fill_or_select, etc.
 77 |    */
 78 |   ref: string;
 79 |   /**
 80 |    * Best-effort stable CSS selector.
 81 |    */
 82 |   selector: string;
 83 |   /**
 84 |    * Selector type (currently CSS only).
 85 |    */
 86 |   selectorType: 'css';
 87 |   /**
 88 |    * Bounding rect in the element's frame viewport coordinates.
 89 |    */
 90 |   rect: PickedElementRect;
 91 |   /**
 92 |    * Center point in the element's frame viewport coordinates.
 93 |    * Can be used as coordinates for chrome_computer.
 94 |    */
 95 |   center: PickedElementPoint;
 96 |   /**
 97 |    * Optional text snippet to help verify the selection.
 98 |    */
 99 |   text?: string;
100 |   /**
101 |    * Lowercased tag name.
102 |    */
103 |   tagName?: string;
104 |   /**
105 |    * Chrome frameId for iframe targeting.
106 |    * Pass this to chrome_click_element/chrome_fill_or_select for cross-frame support.
107 |    */
108 |   frameId: number;
109 | }
110 | 
111 | /**
112 |  * Result for a single element selection request.
113 |  */
114 | export interface ElementPickerResultItem {
115 |   /**
116 |    * The request id (matches the input request).
117 |    */
118 |   id: string;
119 |   /**
120 |    * The request name (for reference).
121 |    */
122 |   name: string;
123 |   /**
124 |    * The picked element, or null if not selected.
125 |    */
126 |   element: PickedElement | null;
127 |   /**
128 |    * Error message if selection failed for this request.
129 |    */
130 |   error?: string;
131 | }
132 | 
133 | /**
134 |  * Result of the chrome_request_element_selection tool.
135 |  */
136 | export interface ElementPickerResult {
137 |   /**
138 |    * True if the user confirmed all selections.
139 |    */
140 |   success: boolean;
141 |   /**
142 |    * Session identifier for this picker session.
143 |    */
144 |   sessionId: string;
145 |   /**
146 |    * Timeout value used for this session.
147 |    */
148 |   timeoutMs: number;
149 |   /**
150 |    * True if the user cancelled the selection.
151 |    */
152 |   cancelled?: boolean;
153 |   /**
154 |    * True if the selection timed out.
155 |    */
156 |   timedOut?: boolean;
157 |   /**
158 |    * List of request IDs that were not selected (for debugging).
159 |    */
160 |   missingRequestIds?: string[];
161 |   /**
162 |    * Results for each requested element.
163 |    */
164 |   results: ElementPickerResultItem[];
165 | }
166 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/styles/tailwind.css:
--------------------------------------------------------------------------------

```css
  1 | @import 'tailwindcss';
  2 | 
  3 | /* App background and card helpers */
  4 | @layer base {
  5 |   html,
  6 |   body,
  7 |   #app {
  8 |     height: 100%;
  9 |   }
 10 |   body {
 11 |     @apply bg-slate-50 text-slate-800;
 12 |   }
 13 | 
 14 |   /* Record&Replay builder design tokens */
 15 |   .rr-theme {
 16 |     --rr-bg: #f8fafc;
 17 |     --rr-topbar: rgba(255, 255, 255, 0.9);
 18 |     --rr-card: #ffffff;
 19 |     --rr-elevated: #ffffff;
 20 |     --rr-border: #e5e7eb;
 21 |     --rr-subtle: #f3f4f6;
 22 |     --rr-text: #0f172a;
 23 |     --rr-text-weak: #475569;
 24 |     --rr-muted: #64748b;
 25 |     --rr-brand: #7c3aed;
 26 |     --rr-brand-strong: #5b21b6;
 27 |     --rr-accent: #0ea5e9;
 28 |     --rr-success: #10b981;
 29 |     --rr-warn: #f59e0b;
 30 |     --rr-danger: #ef4444;
 31 |     --rr-dot: rgba(2, 6, 23, 0.08);
 32 |   }
 33 |   .rr-theme[data-theme='dark'] {
 34 |     --rr-bg: #0b1020;
 35 |     --rr-topbar: rgba(12, 15, 24, 0.8);
 36 |     --rr-card: #0f1528;
 37 |     --rr-elevated: #121a33;
 38 |     --rr-border: rgba(255, 255, 255, 0.08);
 39 |     --rr-subtle: rgba(255, 255, 255, 0.04);
 40 |     --rr-text: #e5e7eb;
 41 |     --rr-text-weak: #cbd5e1;
 42 |     --rr-muted: #94a3b8;
 43 |     --rr-brand: #a78bfa;
 44 |     --rr-brand-strong: #7c3aed;
 45 |     --rr-accent: #38bdf8;
 46 |     --rr-success: #34d399;
 47 |     --rr-warn: #fbbf24;
 48 |     --rr-danger: #f87171;
 49 |     --rr-dot: rgba(226, 232, 240, 0.08);
 50 |   }
 51 | }
 52 | 
 53 | @layer components {
 54 |   .card {
 55 |     @apply rounded-xl shadow-md border;
 56 |     background: var(--rr-card);
 57 |     border-color: var(--rr-border);
 58 |   }
 59 |   /* Generic buttons used across builder */
 60 |   .btn {
 61 |     @apply inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition;
 62 |     background: var(--rr-card);
 63 |     color: var(--rr-text);
 64 |     border: 1px solid var(--rr-border);
 65 |   }
 66 |   .btn:hover {
 67 |     @apply shadow-sm;
 68 |     background: var(--rr-subtle);
 69 |   }
 70 |   .btn[disabled] {
 71 |     @apply opacity-60 cursor-not-allowed;
 72 |   }
 73 |   .btn.primary {
 74 |     color: #fff;
 75 |     background: var(--rr-brand-strong);
 76 |     border-color: var(--rr-brand-strong);
 77 |   }
 78 |   .btn.primary:hover {
 79 |     filter: brightness(1.05);
 80 |   }
 81 |   .btn.ghost {
 82 |     background: transparent;
 83 |     border-color: transparent;
 84 |   }
 85 | 
 86 |   .mini {
 87 |     @apply inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium;
 88 |     background: var(--rr-card);
 89 |     color: var(--rr-text);
 90 |     border: 1px solid var(--rr-border);
 91 |   }
 92 |   .mini:hover {
 93 |     background: var(--rr-subtle);
 94 |   }
 95 |   .mini.danger {
 96 |     background: color-mix(in oklab, var(--rr-danger) 8%, transparent);
 97 |     border-color: color-mix(in oklab, var(--rr-danger) 24%, var(--rr-border));
 98 |     color: var(--rr-text);
 99 |   }
100 | 
101 |   .input {
102 |     @apply w-full px-3 py-2 rounded-lg text-sm;
103 |     background: var(--rr-card);
104 |     color: var(--rr-text);
105 |     border: 1px solid var(--rr-border);
106 |     outline: none;
107 |   }
108 |   .input:focus {
109 |     box-shadow: 0 0 0 3px color-mix(in oklab, var(--rr-brand) 26%, transparent);
110 |     border-color: var(--rr-brand);
111 |   }
112 |   .select {
113 |     @apply w-full px-3 py-2 rounded-lg text-sm;
114 |     background: var(--rr-card);
115 |     color: var(--rr-text);
116 |     border: 1px solid var(--rr-border);
117 |     outline: none;
118 |   }
119 |   .textarea {
120 |     @apply w-full rounded-lg text-sm;
121 |     padding: 10px 12px;
122 |     background: var(--rr-card);
123 |     color: var(--rr-text);
124 |     border: 1px solid var(--rr-border);
125 |     outline: none;
126 |   }
127 |   .label {
128 |     @apply text-sm;
129 |     color: var(--rr-muted);
130 |   }
131 |   .badge {
132 |     @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
133 |   }
134 |   .badge-purple {
135 |     background: color-mix(in oklab, var(--rr-brand) 14%, transparent);
136 |     color: var(--rr-brand);
137 |   }
138 | 
139 |   /* Builder topbar */
140 |   .rr-topbar {
141 |     height: 56px;
142 |     border-bottom: 1px solid var(--rr-border);
143 |     background: var(--rr-topbar);
144 |   }
145 | 
146 |   /* Dot grid background utility for canvas container */
147 |   .rr-dot-grid {
148 |     background-image: radial-gradient(var(--rr-dot) 1px, transparent 1px);
149 |     background-size: 20px 20px;
150 |   }
151 | }
152 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div class="form-section">
  3 |     <div class="section-header">
  4 |       <span class="section-title">{{ title || '选择器' }}</span>
  5 |       <button v-if="allowPick" class="btn-sm btn-primary" @click="pickFromPage">从页面选择</button>
  6 |     </div>
  7 |     <div class="selector-list" data-field="target.candidates">
  8 |       <div class="selector-item" v-for="(c, i) in list" :key="i">
  9 |         <select class="form-select-sm" v-model="c.type">
 10 |           <option value="css">CSS</option>
 11 |           <option value="attr">Attr</option>
 12 |           <option value="aria">ARIA</option>
 13 |           <option value="text">Text</option>
 14 |           <option value="xpath">XPath</option>
 15 |         </select>
 16 |         <input class="form-input-sm flex-1" v-model="c.value" placeholder="选择器值" />
 17 |         <button class="btn-icon-sm" @click="move(i, -1)" :disabled="i === 0">↑</button>
 18 |         <button class="btn-icon-sm" @click="move(i, 1)" :disabled="i === list.length - 1">↓</button>
 19 |         <button class="btn-icon-sm danger" @click="remove(i)">×</button>
 20 |       </div>
 21 |       <button class="btn-sm" @click="add">+ 添加选择器</button>
 22 |     </div>
 23 |   </div>
 24 | </template>
 25 | 
 26 | <script lang="ts" setup>
 27 | /* eslint-disable vue/no-mutating-props */
 28 | import type { NodeBase } from '@/entrypoints/background/record-replay/types';
 29 | 
 30 | const props = defineProps<{
 31 |   node: NodeBase;
 32 |   allowPick?: boolean;
 33 |   targetKey?: string;
 34 |   title?: string;
 35 | }>();
 36 | const key = (props.targetKey || 'target') as string;
 37 | 
 38 | function ensureTarget() {
 39 |   const n: any = props.node;
 40 |   if (!n.config) n.config = {};
 41 |   if (!n.config[key]) n.config[key] = { candidates: [] };
 42 |   if (!Array.isArray(n.config[key].candidates)) n.config[key].candidates = [];
 43 | }
 44 | 
 45 | const list = {
 46 |   get value() {
 47 |     ensureTarget();
 48 |     return ((props.node as any).config[key].candidates || []) as Array<{
 49 |       type: string;
 50 |       value: string;
 51 |     }>;
 52 |   },
 53 | } as any as Array<{ type: string; value: string }>;
 54 | 
 55 | function add() {
 56 |   ensureTarget();
 57 |   (props.node as any).config[key].candidates.push({ type: 'css', value: '' });
 58 | }
 59 | function remove(i: number) {
 60 |   ensureTarget();
 61 |   (props.node as any).config[key].candidates.splice(i, 1);
 62 | }
 63 | function move(i: number, d: number) {
 64 |   ensureTarget();
 65 |   const arr = (props.node as any).config[key].candidates as any[];
 66 |   const j = i + d;
 67 |   if (j < 0 || j >= arr.length) return;
 68 |   const t = arr[i];
 69 |   arr[i] = arr[j];
 70 |   arr[j] = t;
 71 | }
 72 | 
 73 | async function ensurePickerInjected(tabId: number) {
 74 |   try {
 75 |     const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);
 76 |     if (pong && pong.status === 'pong') return;
 77 |   } catch {}
 78 |   try {
 79 |     await chrome.scripting.executeScript({
 80 |       target: { tabId },
 81 |       files: ['inject-scripts/accessibility-tree-helper.js'],
 82 |       world: 'ISOLATED',
 83 |     } as any);
 84 |   } catch (e) {
 85 |     console.warn('inject picker helper failed:', e);
 86 |   }
 87 | }
 88 | 
 89 | async function pickFromPage() {
 90 |   try {
 91 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
 92 |     const tabId = tabs?.[0]?.id;
 93 |     if (typeof tabId !== 'number') return;
 94 |     await ensurePickerInjected(tabId);
 95 |     const resp: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);
 96 |     if (!resp || !resp.success) return;
 97 |     ensureTarget();
 98 |     const n: any = props.node;
 99 |     const arr = Array.isArray(resp.candidates) ? resp.candidates : [];
100 |     const seen = new Set<string>();
101 |     const merged: any[] = [];
102 |     for (const c of arr) {
103 |       if (!c || !c.type || !c.value) continue;
104 |       const key = `${c.type}|${c.value}`;
105 |       if (!seen.has(key)) {
106 |         seen.add(key);
107 |         merged.push({ type: String(c.type), value: String(c.value) });
108 |       }
109 |     }
110 |     n.config[key].candidates = merged;
111 |   } catch (e) {
112 |     console.warn('pickFromPage failed:', e);
113 |   }
114 | }
115 | </script>
116 | 
117 | <style scoped>
118 | /* No local styles; inherit from parent panel via :deep selectors */
119 | </style>
120 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/selector/strategies/testid.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * TestID Strategy - Attribute-based selector strategy
  3 |  *
  4 |  * Generates selectors based on stable attributes like data-testid, data-cy,
  5 |  * as well as semantic attributes like name, title, and alt.
  6 |  */
  7 | 
  8 | import type { SelectorCandidate, SelectorStrategy } from '../types';
  9 | 
 10 | // =============================================================================
 11 | // Constants
 12 | // =============================================================================
 13 | 
 14 | /** Tags that commonly use form-related attributes */
 15 | const FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'button']);
 16 | 
 17 | /** Tags that commonly use the 'alt' attribute */
 18 | const ALT_ATTRIBUTE_TAGS = new Set(['img', 'area']);
 19 | 
 20 | /** Tags that commonly use the 'title' attribute (most elements can have it) */
 21 | const TITLE_ATTRIBUTE_TAGS = new Set(['img', 'a', 'abbr', 'iframe', 'link']);
 22 | 
 23 | /**
 24 |  * Mapping of attributes to their preferred tag prefixes.
 25 |  * When an attribute-only selector is not unique, we try tag-prefixed form
 26 |  * only for elements where that attribute is semantically meaningful.
 27 |  */
 28 | const ATTR_TAG_PREFERENCES: Record<string, Set<string>> = {
 29 |   name: FORM_ELEMENT_TAGS,
 30 |   alt: ALT_ATTRIBUTE_TAGS,
 31 |   title: TITLE_ATTRIBUTE_TAGS,
 32 | };
 33 | 
 34 | // =============================================================================
 35 | // Helpers
 36 | // =============================================================================
 37 | 
 38 | function makeAttrSelector(attr: string, value: string, cssEscape: (v: string) => string): string {
 39 |   return `[${attr}="${cssEscape(value)}"]`;
 40 | }
 41 | 
 42 | /**
 43 |  * Determine if tag prefix should be tried for disambiguation.
 44 |  *
 45 |  * Rules:
 46 |  * - data-* attributes: try for form elements only
 47 |  * - name: try for form elements (input, textarea, select, button)
 48 |  * - alt: try for img, area, input[type=image]
 49 |  * - title: try for common elements that use title semantically
 50 |  * - Default: try for any tag
 51 |  */
 52 | function shouldTryTagPrefix(attr: string, tag: string, element: Element): boolean {
 53 |   if (!tag) return false;
 54 | 
 55 |   // For data-* test attributes, use form element heuristic
 56 |   if (attr.startsWith('data-')) {
 57 |     return FORM_ELEMENT_TAGS.has(tag);
 58 |   }
 59 | 
 60 |   // For semantic attributes, check the preference mapping
 61 |   const preferredTags = ATTR_TAG_PREFERENCES[attr];
 62 |   if (preferredTags) {
 63 |     if (preferredTags.has(tag)) return true;
 64 | 
 65 |     // Special case: input[type=image] also uses alt
 66 |     if (attr === 'alt' && tag === 'input') {
 67 |       const type = element.getAttribute('type');
 68 |       return type === 'image';
 69 |     }
 70 | 
 71 |     return false;
 72 |   }
 73 | 
 74 |   // Default: try tag prefix for any element
 75 |   return true;
 76 | }
 77 | 
 78 | // =============================================================================
 79 | // Strategy Export
 80 | // =============================================================================
 81 | 
 82 | export const testIdStrategy: SelectorStrategy = {
 83 |   id: 'testid',
 84 | 
 85 |   generate(ctx) {
 86 |     const { element, options, helpers } = ctx;
 87 |     const out: SelectorCandidate[] = [];
 88 |     const tag = element.tagName?.toLowerCase?.() ?? '';
 89 | 
 90 |     for (const attr of options.testIdAttributes) {
 91 |       const raw = element.getAttribute(attr);
 92 |       const value = raw?.trim();
 93 |       if (!value) continue;
 94 | 
 95 |       const attrOnly = makeAttrSelector(attr, value, helpers.cssEscape);
 96 | 
 97 |       // Try attribute-only selector first
 98 |       if (helpers.isUnique(attrOnly)) {
 99 |         out.push({
100 |           type: 'attr',
101 |           value: attrOnly,
102 |           source: 'generated',
103 |           strategy: 'testid',
104 |         });
105 |         continue;
106 |       }
107 | 
108 |       // Try tag-prefixed form if appropriate for this attribute/element combo
109 |       if (shouldTryTagPrefix(attr, tag, element)) {
110 |         const withTag = `${tag}${attrOnly}`;
111 |         if (helpers.isUnique(withTag)) {
112 |           out.push({
113 |             type: 'attr',
114 |             value: withTag,
115 |             source: 'generated',
116 |             strategy: 'testid',
117 |           });
118 |         }
119 |       }
120 |     }
121 | 
122 |     return out;
123 |   },
124 | };
125 | 
```

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

```vue
  1 | <template>
  2 |   <div class="space-y-1">
  3 |     <div class="flex items-baseline gap-2 flex-wrap">
  4 |       <!-- Label -->
  5 |       <span
  6 |         class="text-[11px] font-bold uppercase tracking-wider flex-shrink-0"
  7 |         :style="{
  8 |           color: labelColor,
  9 |         }"
 10 |       >
 11 |         {{ item.tool.label }}
 12 |       </span>
 13 | 
 14 |       <!-- Content based on tool kind -->
 15 |       <code
 16 |         v-if="item.tool.kind === 'grep' || item.tool.kind === 'read'"
 17 |         class="text-xs px-1.5 py-0.5 cursor-pointer ac-chip-hover"
 18 |         :style="{
 19 |           fontFamily: 'var(--ac-font-mono)',
 20 |           backgroundColor: 'var(--ac-chip-bg)',
 21 |           color: 'var(--ac-chip-text)',
 22 |           borderRadius: 'var(--ac-radius-button)',
 23 |         }"
 24 |         :title="item.tool.filePath || item.tool.pattern"
 25 |       >
 26 |         {{ item.tool.title }}
 27 |       </code>
 28 | 
 29 |       <span
 30 |         v-else
 31 |         class="text-xs"
 32 |         :style="{
 33 |           fontFamily: 'var(--ac-font-mono)',
 34 |           color: 'var(--ac-text-muted)',
 35 |         }"
 36 |         :title="item.tool.filePath || item.tool.command"
 37 |       >
 38 |         {{ item.tool.title }}
 39 |       </span>
 40 | 
 41 |       <!-- Diff Stats Preview (for edit) -->
 42 |       <span
 43 |         v-if="hasDiffStats"
 44 |         class="text-[10px] px-1.5 py-0.5"
 45 |         :style="{
 46 |           backgroundColor: 'var(--ac-chip-bg)',
 47 |           color: 'var(--ac-text-muted)',
 48 |           fontFamily: 'var(--ac-font-mono)',
 49 |           borderRadius: 'var(--ac-radius-button)',
 50 |         }"
 51 |       >
 52 |         <span v-if="item.tool.diffStats?.addedLines" class="text-green-600 dark:text-green-400">
 53 |           +{{ item.tool.diffStats.addedLines }}
 54 |         </span>
 55 |         <span v-if="item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines">/</span>
 56 |         <span v-if="item.tool.diffStats?.deletedLines" class="text-red-600 dark:text-red-400">
 57 |           -{{ item.tool.diffStats.deletedLines }}
 58 |         </span>
 59 |       </span>
 60 | 
 61 |       <!-- Streaming indicator -->
 62 |       <span
 63 |         v-if="item.isStreaming"
 64 |         class="text-xs italic"
 65 |         :style="{ color: 'var(--ac-text-subtle)' }"
 66 |       >
 67 |         ...
 68 |       </span>
 69 |     </div>
 70 | 
 71 |     <!-- Subtitle (command description or search path) -->
 72 |     <div
 73 |       v-if="subtitle"
 74 |       class="text-[10px] pl-10 truncate"
 75 |       :style="{ color: 'var(--ac-text-subtle)' }"
 76 |       :title="subtitleFull"
 77 |     >
 78 |       {{ subtitle }}
 79 |     </div>
 80 |   </div>
 81 | </template>
 82 | 
 83 | <script lang="ts" setup>
 84 | import { computed } from 'vue';
 85 | import type { TimelineItem } from '../../../composables/useAgentThreads';
 86 | 
 87 | const props = defineProps<{
 88 |   item: Extract<TimelineItem, { kind: 'tool_use' }>;
 89 | }>();
 90 | 
 91 | const labelColor = computed(() => {
 92 |   if (props.item.tool.kind === 'edit') {
 93 |     return 'var(--ac-accent)';
 94 |   }
 95 |   return 'var(--ac-text-subtle)';
 96 | });
 97 | 
 98 | const hasDiffStats = computed(() => {
 99 |   const stats = props.item.tool.diffStats;
100 |   if (!stats) return false;
101 |   return stats.addedLines !== undefined || stats.deletedLines !== undefined;
102 | });
103 | 
104 | const subtitle = computed(() => {
105 |   const tool = props.item.tool;
106 | 
107 |   // For commands: show the actual command if title is description
108 |   if (tool.kind === 'run' && tool.commandDescription && tool.command) {
109 |     return tool.command.length > 60 ? tool.command.slice(0, 57) + '...' : tool.command;
110 |   }
111 | 
112 |   // For file operations: show full path if title is just filename
113 |   if ((tool.kind === 'edit' || tool.kind === 'read') && tool.filePath) {
114 |     if (tool.filePath !== tool.title && !tool.title.includes('/')) {
115 |       return tool.filePath;
116 |     }
117 |   }
118 | 
119 |   // For search: show search path if provided
120 |   if (tool.kind === 'grep' && tool.searchPath) {
121 |     return `in ${tool.searchPath}`;
122 |   }
123 | 
124 |   return undefined;
125 | });
126 | 
127 | const subtitleFull = computed(() => {
128 |   const tool = props.item.tool;
129 |   if (tool.kind === 'run' && tool.command) return tool.command;
130 |   if (tool.filePath) return tool.filePath;
131 |   if (tool.searchPath) return tool.searchPath;
132 |   return undefined;
133 | });
134 | </script>
135 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/storage-manager.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
  2 | 
  3 | /**
  4 |  * Get storage statistics
  5 |  */
  6 | export async function handleGetStorageStats(): Promise<{
  7 |   success: boolean;
  8 |   stats?: any;
  9 |   error?: string;
 10 | }> {
 11 |   try {
 12 |     // Get ContentIndexer statistics
 13 |     const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
 14 |     const contentIndexer = getGlobalContentIndexer();
 15 | 
 16 |     // Note: Semantic engine initialization is now user-controlled
 17 |     // ContentIndexer will be initialized when user manually triggers semantic engine initialization
 18 | 
 19 |     // Get statistics
 20 |     const stats = contentIndexer.getStats();
 21 | 
 22 |     return {
 23 |       success: true,
 24 |       stats: {
 25 |         indexedPages: stats.indexedPages || 0,
 26 |         totalDocuments: stats.totalDocuments || 0,
 27 |         totalTabs: stats.totalTabs || 0,
 28 |         indexSize: stats.indexSize || 0,
 29 |         isInitialized: stats.isInitialized || false,
 30 |         semanticEngineReady: stats.semanticEngineReady || false,
 31 |         semanticEngineInitializing: stats.semanticEngineInitializing || false,
 32 |       },
 33 |     };
 34 |   } catch (error: any) {
 35 |     console.error('Background: Failed to get storage stats:', error);
 36 |     return {
 37 |       success: false,
 38 |       error: error.message,
 39 |       stats: {
 40 |         indexedPages: 0,
 41 |         totalDocuments: 0,
 42 |         totalTabs: 0,
 43 |         indexSize: 0,
 44 |         isInitialized: false,
 45 |         semanticEngineReady: false,
 46 |         semanticEngineInitializing: false,
 47 |       },
 48 |     };
 49 |   }
 50 | }
 51 | 
 52 | /**
 53 |  * Clear all data
 54 |  */
 55 | export async function handleClearAllData(): Promise<{ success: boolean; error?: string }> {
 56 |   try {
 57 |     // 1. Clear all ContentIndexer indexes
 58 |     try {
 59 |       const { getGlobalContentIndexer } = await import('@/utils/content-indexer');
 60 |       const contentIndexer = getGlobalContentIndexer();
 61 | 
 62 |       await contentIndexer.clearAllIndexes();
 63 |       console.log('Storage: ContentIndexer indexes cleared successfully');
 64 |     } catch (indexerError) {
 65 |       console.warn('Background: Failed to clear ContentIndexer indexes:', indexerError);
 66 |       // Continue with other cleanup operations
 67 |     }
 68 | 
 69 |     // 2. Clear all VectorDatabase data
 70 |     try {
 71 |       const { clearAllVectorData } = await import('@/utils/vector-database');
 72 |       await clearAllVectorData();
 73 |       console.log('Storage: Vector database data cleared successfully');
 74 |     } catch (vectorError) {
 75 |       console.warn('Background: Failed to clear vector data:', vectorError);
 76 |       // Continue with other cleanup operations
 77 |     }
 78 | 
 79 |     // 3. Clear related data in chrome.storage (preserve model preferences)
 80 |     try {
 81 |       const keysToRemove = ['vectorDatabaseStats', 'lastCleanupTime', 'contentIndexerStats'];
 82 |       await chrome.storage.local.remove(keysToRemove);
 83 |       console.log('Storage: Chrome storage data cleared successfully');
 84 |     } catch (storageError) {
 85 |       console.warn('Background: Failed to clear chrome storage data:', storageError);
 86 |     }
 87 | 
 88 |     return { success: true };
 89 |   } catch (error: any) {
 90 |     console.error('Background: Failed to clear all data:', error);
 91 |     return { success: false, error: error.message };
 92 |   }
 93 | }
 94 | 
 95 | /**
 96 |  * Initialize storage manager module message listeners
 97 |  */
 98 | export const initStorageManagerListener = () => {
 99 |   chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
100 |     if (message.type === BACKGROUND_MESSAGE_TYPES.GET_STORAGE_STATS) {
101 |       handleGetStorageStats()
102 |         .then((result: { success: boolean; stats?: any; error?: string }) => sendResponse(result))
103 |         .catch((error: any) => sendResponse({ success: false, error: error.message }));
104 |       return true;
105 |     } else if (message.type === BACKGROUND_MESSAGE_TYPES.CLEAR_ALL_DATA) {
106 |       handleClearAllData()
107 |         .then((result: { success: boolean; error?: string }) => sendResponse(result))
108 |         .catch((error: any) => sendResponse({ success: false, error: error.message }));
109 |       return true;
110 |     }
111 |   });
112 | };
113 | 
```

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

```vue
  1 | <template>
  2 |   <div
  3 |     v-if="open"
  4 |     class="fixed top-12 right-4 z-50 min-w-[180px] py-2"
  5 |     :style="{
  6 |       backgroundColor: 'var(--ac-surface, #ffffff)',
  7 |       border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
  8 |       borderRadius: 'var(--ac-radius-inner, 8px)',
  9 |       boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',
 10 |     }"
 11 |   >
 12 |     <!-- Theme Section -->
 13 |     <div
 14 |       class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
 15 |       :style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
 16 |     >
 17 |       Theme
 18 |     </div>
 19 | 
 20 |     <button
 21 |       v-for="t in themes"
 22 |       :key="t.id"
 23 |       class="w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item"
 24 |       :style="{
 25 |         color: theme === t.id ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',
 26 |       }"
 27 |       @click="$emit('theme:set', t.id)"
 28 |     >
 29 |       <span>{{ t.label }}</span>
 30 |       <svg
 31 |         v-if="theme === t.id"
 32 |         class="w-4 h-4"
 33 |         fill="none"
 34 |         viewBox="0 0 24 24"
 35 |         stroke="currentColor"
 36 |       >
 37 |         <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
 38 |       </svg>
 39 |     </button>
 40 | 
 41 |     <!-- Divider -->
 42 |     <div
 43 |       class="my-2"
 44 |       :style="{
 45 |         borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
 46 |       }"
 47 |     />
 48 | 
 49 |     <!-- Input Section -->
 50 |     <div
 51 |       class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
 52 |       :style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
 53 |     >
 54 |       Input
 55 |     </div>
 56 | 
 57 |     <button
 58 |       class="w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item"
 59 |       :style="{ color: 'var(--ac-text, #1a1a1a)' }"
 60 |       @click="$emit('fakeCaret:toggle', !fakeCaretEnabled)"
 61 |     >
 62 |       <span>Comet caret</span>
 63 |       <svg
 64 |         v-if="fakeCaretEnabled"
 65 |         class="w-4 h-4"
 66 |         fill="none"
 67 |         viewBox="0 0 24 24"
 68 |         stroke="currentColor"
 69 |       >
 70 |         <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
 71 |       </svg>
 72 |     </button>
 73 | 
 74 |     <!-- Divider -->
 75 |     <div
 76 |       class="my-2"
 77 |       :style="{
 78 |         borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
 79 |       }"
 80 |     />
 81 | 
 82 |     <!-- Storage Section -->
 83 |     <div
 84 |       class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
 85 |       :style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
 86 |     >
 87 |       Storage
 88 |     </div>
 89 | 
 90 |     <button
 91 |       class="w-full px-3 py-2 text-left text-sm ac-menu-item"
 92 |       :style="{ color: 'var(--ac-text, #1a1a1a)' }"
 93 |       @click="$emit('attachments:open')"
 94 |     >
 95 |       Clear Attachment Cache
 96 |     </button>
 97 | 
 98 |     <!-- Divider -->
 99 |     <div
100 |       class="my-2"
101 |       :style="{
102 |         borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
103 |       }"
104 |     />
105 | 
106 |     <!-- Reconnect -->
107 |     <button
108 |       class="w-full px-3 py-2 text-left text-sm ac-menu-item"
109 |       :style="{ color: 'var(--ac-text, #1a1a1a)' }"
110 |       @click="$emit('reconnect')"
111 |     >
112 |       Reconnect Server
113 |     </button>
114 |   </div>
115 | </template>
116 | 
117 | <script lang="ts" setup>
118 | import { type AgentThemeId, THEME_LABELS } from '../../composables';
119 | 
120 | defineProps<{
121 |   open: boolean;
122 |   theme: AgentThemeId;
123 |   /** Fake caret (comet effect) enabled state */
124 |   fakeCaretEnabled?: boolean;
125 | }>();
126 | 
127 | defineEmits<{
128 |   'theme:set': [theme: AgentThemeId];
129 |   reconnect: [];
130 |   'attachments:open': [];
131 |   'fakeCaret:toggle': [enabled: boolean];
132 | }>();
133 | 
134 | const themes: { id: AgentThemeId; label: string }[] = [
135 |   { id: 'warm-editorial', label: THEME_LABELS['warm-editorial'] },
136 |   { id: 'blueprint-architect', label: THEME_LABELS['blueprint-architect'] },
137 |   { id: 'zen-journal', label: THEME_LABELS['zen-journal'] },
138 |   { id: 'neo-pop', label: THEME_LABELS['neo-pop'] },
139 |   { id: 'dark-console', label: THEME_LABELS['dark-console'] },
140 |   { id: 'swiss-grid', label: THEME_LABELS['swiss-grid'] },
141 | ];
142 | </script>
143 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue:
--------------------------------------------------------------------------------

```vue
  1 | <template>
  2 |   <div class="form-section">
  3 |     <div class="section-header">
  4 |       <span class="section-title">If / else</span>
  5 |       <button class="btn-sm" @click="addIfCase">+ Add</button>
  6 |     </div>
  7 |     <div class="text-xs text-slate-500" style="padding: 0 20px"
  8 |       >使用表达式定义分支,支持变量与常见比较运算符。</div
  9 |     >
 10 |     <div class="if-case-list" data-field="if.branches">
 11 |       <div class="if-case-item" v-for="(c, i) in ifBranches" :key="c.id">
 12 |         <div class="if-case-header">
 13 |           <input class="form-input-sm flex-1" v-model="c.name" placeholder="分支名称(可选)" />
 14 |           <button class="btn-icon-sm danger" @click="removeIfCase(i)" title="删除">×</button>
 15 |         </div>
 16 |         <div class="if-case-expr">
 17 |           <VarInput
 18 |             v-model="c.expr"
 19 |             :variables="variablesNormalized"
 20 |             format="workflowDot"
 21 |             :placeholder="'workflow.' + (variablesNormalized[0]?.key || 'var') + ' == 5'"
 22 |           />
 23 |           <div class="if-toolbar">
 24 |             <select
 25 |               class="form-select-sm"
 26 |               @change="(e: any) => insertVar(e.target.value, i)"
 27 |               :value="''"
 28 |             >
 29 |               <option value="" disabled>插入变量</option>
 30 |               <option v-for="v in variables" :key="v.key" :value="v.key">{{ v.key }}</option>
 31 |             </select>
 32 |             <select
 33 |               class="form-select-sm"
 34 |               @change="(e: any) => insertOp(e.target.value, i)"
 35 |               :value="''"
 36 |             >
 37 |               <option value="" disabled>运算符</option>
 38 |               <option v-for="op in ops" :key="op" :value="op">{{ op }}</option>
 39 |             </select>
 40 |           </div>
 41 |         </div>
 42 |       </div>
 43 |       <div class="if-case-else" v-if="elseEnabled">
 44 |         <div class="text-xs text-slate-500">Else 分支(无需表达式,将匹配以上条件都不成立时)</div>
 45 |       </div>
 46 |     </div>
 47 |   </div>
 48 | </template>
 49 | 
 50 | <script lang="ts" setup>
 51 | /* eslint-disable vue/no-mutating-props */
 52 | import { computed } from 'vue';
 53 | import type { NodeBase } from '@/entrypoints/background/record-replay/types';
 54 | import { newId } from '@/entrypoints/popup/components/builder/model/transforms';
 55 | 
 56 | import VarInput from '@/entrypoints/popup/components/builder/widgets/VarInput.vue';
 57 | import type { VariableOption } from '@/entrypoints/popup/components/builder/model/variables';
 58 | const props = defineProps<{ node: NodeBase; variables?: Array<{ key: string }> }>();
 59 | const variablesNormalized = computed<VariableOption[]>(() =>
 60 |   (props.variables || []).map((v) => ({ key: v.key, origin: 'global' }) as VariableOption),
 61 | );
 62 | 
 63 | const ops = ['==', '!=', '>', '>=', '<', '<=', '&&', '||'];
 64 | const ifBranches = computed<Array<{ id: string; name?: string; expr: string }>>({
 65 |   get() {
 66 |     try {
 67 |       return Array.isArray((props.node as any)?.config?.branches)
 68 |         ? ((props.node as any).config.branches as any[])
 69 |         : [];
 70 |     } catch {
 71 |       return [] as any;
 72 |     }
 73 |   },
 74 |   set(arr) {
 75 |     try {
 76 |       (props.node as any).config.branches = arr;
 77 |     } catch {}
 78 |   },
 79 | });
 80 | const elseEnabled = computed<boolean>({
 81 |   get() {
 82 |     try {
 83 |       return (props.node as any)?.config?.else !== false;
 84 |     } catch {
 85 |       return true;
 86 |     }
 87 |   },
 88 |   set(v) {
 89 |     try {
 90 |       (props.node as any).config.else = !!v;
 91 |     } catch {}
 92 |   },
 93 | });
 94 | 
 95 | function addIfCase() {
 96 |   const arr = ifBranches.value.slice();
 97 |   arr.push({ id: newId('case'), name: '', expr: '' });
 98 |   ifBranches.value = arr;
 99 | }
100 | function removeIfCase(i: number) {
101 |   const arr = ifBranches.value.slice();
102 |   arr.splice(i, 1);
103 |   ifBranches.value = arr;
104 | }
105 | function insertVar(key: string, idx: number) {
106 |   if (!key) return;
107 |   const arr = ifBranches.value.slice();
108 |   const token = `workflow.${key}`;
109 |   arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + token;
110 |   ifBranches.value = arr;
111 | }
112 | function insertOp(op: string, idx: number) {
113 |   if (!op) return;
114 |   const arr = ifBranches.value.slice();
115 |   arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + op;
116 |   ifBranches.value = arr;
117 | }
118 | </script>
119 | 
120 | <style scoped></style>
121 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { TOOL_NAMES } from 'chrome-mcp-shared';
 2 | import { handleCallTool } from '@/entrypoints/background/tools';
 3 | import type { StepAssert } from '../types';
 4 | import { expandTemplatesDeep } from '../rr-utils';
 5 | import type { ExecCtx, ExecResult, NodeRuntime } from './types';
 6 | 
 7 | export const assertNode: NodeRuntime<StepAssert> = {
 8 |   validate: (step) => {
 9 |     const s = step as any;
10 |     const ok = !!s.assert;
11 |     if (ok && s.assert && 'attribute' in s.assert) {
12 |       const a = s.assert.attribute || {};
13 |       if (!a.selector || !a.name)
14 |         return { ok: false, errors: ['assert.attribute: 需提供 selector 与 name'] };
15 |     }
16 |     return ok ? { ok } : { ok, errors: ['缺少断言条件'] };
17 |   },
18 |   run: async (ctx: ExecCtx, step: StepAssert) => {
19 |     const s = expandTemplatesDeep(step as StepAssert, ctx.vars) as any;
20 |     const failStrategy = (s as any).failStrategy || 'stop';
21 |     const fail = (msg: string) => {
22 |       if (failStrategy === 'warn') {
23 |         ctx.logger({ stepId: (step as any).id, status: 'warning', message: msg });
24 |         return { alreadyLogged: true } as any;
25 |       }
26 |       throw new Error(msg);
27 |     };
28 |     if ('textPresent' in s.assert) {
29 |       const text = (s.assert as any).textPresent;
30 |       const res = await handleCallTool({
31 |         name: TOOL_NAMES.BROWSER.COMPUTER,
32 |         args: { action: 'wait', text, appear: true, timeout: (step as any).timeoutMs || 5000 },
33 |       });
34 |       if ((res as any).isError) return fail('assert text failed');
35 |     } else if ('exists' in s.assert || 'visible' in s.assert) {
36 |       const selector = (s.assert as any).exists || (s.assert as any).visible;
37 |       const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
38 |       const firstTab = tabs && tabs[0];
39 |       const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
40 |       if (!tabId) return fail('Active tab not found');
41 |       await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
42 |       const ensured: any = (await chrome.tabs.sendMessage(
43 |         tabId,
44 |         {
45 |           action: 'ensureRefForSelector',
46 |           selector,
47 |         } as any,
48 |         { frameId: ctx.frameId } as any,
49 |       )) as any;
50 |       if (!ensured || !ensured.success) return fail('assert selector not found');
51 |       if ('visible' in s.assert) {
52 |         const rect = ensured && ensured.center ? ensured.center : null;
53 |         if (!rect) return fail('assert visible failed');
54 |       }
55 |     } else if ('attribute' in s.assert) {
56 |       const { selector, name, equals, matches } = (s.assert as any).attribute || {};
57 |       const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
58 |       const firstTab = tabs && tabs[0];
59 |       const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
60 |       if (!tabId) return fail('Active tab not found');
61 |       await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
62 |       const resp: any = (await chrome.tabs.sendMessage(
63 |         tabId,
64 |         { action: 'getAttributeForSelector', selector, name } as any,
65 |         { frameId: ctx.frameId } as any,
66 |       )) as any;
67 |       if (!resp || !resp.success) return fail('assert attribute: element not found');
68 |       const actual: string | null = resp.value ?? null;
69 |       if (equals !== undefined && equals !== null) {
70 |         const expected = String(equals);
71 |         if (String(actual) !== String(expected))
72 |           return fail(
73 |             `assert attribute equals failed: ${name} actual=${String(actual)} expected=${String(expected)}`,
74 |           );
75 |       } else if (matches !== undefined && matches !== null) {
76 |         try {
77 |           const re = new RegExp(String(matches));
78 |           if (!re.test(String(actual)))
79 |             return fail(
80 |               `assert attribute matches failed: ${name} actual=${String(actual)} regex=${String(matches)}`,
81 |             );
82 |         } catch {
83 |           return fail(`invalid regex for attribute matches: ${String(matches)}`);
84 |         }
85 |       } else {
86 |         if (actual == null) return fail(`assert attribute failed: ${name} missing`);
87 |       }
88 |     }
89 |     return {} as ExecResult;
90 |   },
91 | };
92 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/agent/engines/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { AgentAttachment, RealtimeEvent } from '../types';
  2 | import type { CodexEngineConfig } from 'chrome-mcp-shared';
  3 | 
  4 | export interface EngineInitOptions {
  5 |   sessionId: string;
  6 |   instruction: string;
  7 |   model?: string;
  8 |   projectRoot?: string;
  9 |   requestId: string;
 10 |   /**
 11 |    * AbortSignal for cancellation support.
 12 |    */
 13 |   signal?: AbortSignal;
 14 |   /**
 15 |    * Optional attachments (images/files) to include with the instruction.
 16 |    * Note: When using persisted attachments, use resolvedImagePaths instead.
 17 |    */
 18 |   attachments?: AgentAttachment[];
 19 |   /**
 20 |    * Resolved absolute paths to persisted image files.
 21 |    * These are used by engines instead of writing temp files from base64.
 22 |    * Set by chat-service after saving attachments to persistent storage.
 23 |    */
 24 |   resolvedImagePaths?: string[];
 25 |   /**
 26 |    * Optional project ID for session persistence.
 27 |    * When provided, engines can use this to save/load session state.
 28 |    */
 29 |   projectId?: string;
 30 |   /**
 31 |    * Optional database session ID (sessions.id) for session-scoped configuration and persistence.
 32 |    */
 33 |   dbSessionId?: string;
 34 |   /**
 35 |    * Optional session-scoped permission mode override (Claude SDK option).
 36 |    */
 37 |   permissionMode?: string;
 38 |   /**
 39 |    * Optional session-scoped permission bypass override (Claude SDK option).
 40 |    */
 41 |   allowDangerouslySkipPermissions?: boolean;
 42 |   /**
 43 |    * Optional session-scoped system prompt configuration.
 44 |    */
 45 |   systemPromptConfig?: unknown;
 46 |   /**
 47 |    * Optional session-scoped engine option overrides.
 48 |    */
 49 |   optionsConfig?: unknown;
 50 |   /**
 51 |    * Optional Claude session ID (UUID) for resuming a previous session.
 52 |    * Only applicable to ClaudeEngine; retrieved from sessions.engineSessionId (preferred)
 53 |    * or project's activeClaudeSessionId (legacy fallback).
 54 |    */
 55 |   resumeClaudeSessionId?: string;
 56 |   /**
 57 |    * Whether to use Claude Code Router (CCR) for this request.
 58 |    * Only applicable to ClaudeEngine; when true, CCR will be auto-detected.
 59 |    */
 60 |   useCcr?: boolean;
 61 |   /**
 62 |    * Optional Codex-specific configuration overrides.
 63 |    * Only applicable to CodexEngine; merged with DEFAULT_CODEX_CONFIG.
 64 |    */
 65 |   codexConfig?: Partial<CodexEngineConfig>;
 66 | }
 67 | 
 68 | /**
 69 |  * Callback to persist Claude session ID after initialization.
 70 |  */
 71 | export type ClaudeSessionPersistCallback = (sessionId: string) => Promise<void>;
 72 | 
 73 | /**
 74 |  * Management information extracted from Claude SDK system:init message.
 75 |  */
 76 | export interface ClaudeManagementInfo {
 77 |   tools?: string[];
 78 |   agents?: string[];
 79 |   /** Plugins with name and path (SDK returns { name, path }[]) */
 80 |   plugins?: Array<{ name: string; path?: string }>;
 81 |   skills?: string[];
 82 |   mcpServers?: Array<{ name: string; status: string }>;
 83 |   slashCommands?: string[];
 84 |   model?: string;
 85 |   permissionMode?: string;
 86 |   cwd?: string;
 87 |   outputStyle?: string;
 88 |   betas?: string[];
 89 |   claudeCodeVersion?: string;
 90 |   apiKeySource?: string;
 91 | }
 92 | 
 93 | /**
 94 |  * Callback to persist management information after SDK initialization.
 95 |  */
 96 | export type ManagementInfoPersistCallback = (info: ClaudeManagementInfo) => Promise<void>;
 97 | 
 98 | export type EngineName = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
 99 | 
100 | export interface EngineExecutionContext {
101 |   /**
102 |    * Emit a realtime event to all connected clients for the current session.
103 |    */
104 |   emit(event: RealtimeEvent): void;
105 |   /**
106 |    * Optional callback to persist Claude session ID after SDK initialization.
107 |    * Only called by ClaudeEngine when projectId is provided.
108 |    */
109 |   persistClaudeSessionId?: ClaudeSessionPersistCallback;
110 |   /**
111 |    * Optional callback to persist management information after SDK initialization.
112 |    * Only called by ClaudeEngine when dbSessionId is provided.
113 |    */
114 |   persistManagementInfo?: ManagementInfoPersistCallback;
115 | }
116 | 
117 | export interface AgentEngine {
118 |   name: EngineName;
119 |   /**
120 |    * Whether this engine can act as an MCP client natively.
121 |    */
122 |   supportsMcp?: boolean;
123 |   initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void>;
124 | }
125 | 
126 | /**
127 |  * Represents a running engine execution that can be cancelled.
128 |  */
129 | export interface RunningExecution {
130 |   requestId: string;
131 |   sessionId: string;
132 |   engineName: EngineName;
133 |   abortController: AbortController;
134 |   startedAt: Date;
135 | }
136 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/utils/indexeddb-client.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // indexeddb-client.ts
  2 | // Generic IndexedDB client with robust transaction handling and small helpers.
  3 | 
  4 | export type UpgradeHandler = (
  5 |   db: IDBDatabase,
  6 |   oldVersion: number,
  7 |   tx: IDBTransaction | null,
  8 | ) => void;
  9 | 
 10 | export class IndexedDbClient {
 11 |   private dbPromise: Promise<IDBDatabase> | null = null;
 12 | 
 13 |   constructor(
 14 |     private name: string,
 15 |     private version: number,
 16 |     private onUpgrade: UpgradeHandler,
 17 |   ) {}
 18 | 
 19 |   async openDb(): Promise<IDBDatabase> {
 20 |     if (this.dbPromise) return this.dbPromise;
 21 |     this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
 22 |       const req = indexedDB.open(this.name, this.version);
 23 |       req.onupgradeneeded = (event) => {
 24 |         const db = req.result;
 25 |         const oldVersion = (event as IDBVersionChangeEvent).oldVersion || 0;
 26 |         const tx = req.transaction as IDBTransaction | null;
 27 |         try {
 28 |           this.onUpgrade(db, oldVersion, tx);
 29 |         } catch (e) {
 30 |           console.error('IndexedDbClient upgrade failed:', e);
 31 |         }
 32 |       };
 33 |       req.onsuccess = () => resolve(req.result);
 34 |       req.onerror = () =>
 35 |         reject(new Error(`IndexedDB open failed: ${req.error?.message || req.error}`));
 36 |     });
 37 |     return this.dbPromise;
 38 |   }
 39 | 
 40 |   async tx<T>(
 41 |     storeName: string,
 42 |     mode: IDBTransactionMode,
 43 |     op: (store: IDBObjectStore, txn: IDBTransaction) => T | Promise<T>,
 44 |   ): Promise<T> {
 45 |     const db = await this.openDb();
 46 |     return new Promise<T>((resolve, reject) => {
 47 |       const transaction = db.transaction(storeName, mode);
 48 |       const st = transaction.objectStore(storeName);
 49 |       let opResult: T | undefined;
 50 |       let opError: any;
 51 |       transaction.oncomplete = () => resolve(opResult as T);
 52 |       transaction.onerror = () =>
 53 |         reject(
 54 |           new Error(
 55 |             `IDB transaction error on ${storeName}: ${transaction.error?.message || transaction.error}`,
 56 |           ),
 57 |         );
 58 |       transaction.onabort = () =>
 59 |         reject(
 60 |           new Error(
 61 |             `IDB transaction aborted on ${storeName}: ${transaction.error?.message || opError || 'unknown'}`,
 62 |           ),
 63 |         );
 64 |       Promise.resolve()
 65 |         .then(() => op(st, transaction))
 66 |         .then((res) => {
 67 |           opResult = res as T;
 68 |         })
 69 |         .catch((err) => {
 70 |           opError = err;
 71 |           try {
 72 |             transaction.abort();
 73 |           } catch {}
 74 |         });
 75 |     });
 76 |   }
 77 | 
 78 |   async getAll<T>(store: string): Promise<T[]> {
 79 |     return this.tx<T[]>(store, 'readonly', (st) =>
 80 |       this.promisifyRequest<any[]>(st.getAll(), store, 'getAll').then((res) => (res as T[]) || []),
 81 |     );
 82 |   }
 83 | 
 84 |   async get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {
 85 |     return this.tx<T | undefined>(store, 'readonly', (st) =>
 86 |       this.promisifyRequest<T | undefined>(st.get(key), store, `get(${String(key)})`).then(
 87 |         (res) => res as any,
 88 |       ),
 89 |     );
 90 |   }
 91 | 
 92 |   async put<T>(store: string, value: T): Promise<void> {
 93 |     return this.tx<void>(store, 'readwrite', (st) =>
 94 |       this.promisifyRequest<any>(st.put(value as any), store, 'put').then(() => undefined),
 95 |     );
 96 |   }
 97 | 
 98 |   async delete(store: string, key: IDBValidKey): Promise<void> {
 99 |     return this.tx<void>(store, 'readwrite', (st) =>
100 |       this.promisifyRequest<any>(st.delete(key), store, `delete(${String(key)})`).then(
101 |         () => undefined,
102 |       ),
103 |     );
104 |   }
105 | 
106 |   async clear(store: string): Promise<void> {
107 |     return this.tx<void>(store, 'readwrite', (st) =>
108 |       this.promisifyRequest<any>(st.clear(), store, 'clear').then(() => undefined),
109 |     );
110 |   }
111 | 
112 |   async putMany<T>(store: string, values: T[]): Promise<void> {
113 |     return this.tx<void>(store, 'readwrite', async (st) => {
114 |       for (const v of values) st.put(v as any);
115 |       return;
116 |     });
117 |   }
118 | 
119 |   // Expose helper for advanced callers if needed
120 |   promisifyRequest<R>(req: IDBRequest<R>, store: string, action: string): Promise<R> {
121 |     return new Promise<R>((resolve, reject) => {
122 |       req.onsuccess = () => resolve(req.result as R);
123 |       req.onerror = () =>
124 |         reject(
125 |           new Error(
126 |             `IDB ${action} error on ${store}: ${(req.error as any)?.message || (req.error as any)}`,
127 |           ),
128 |         );
129 |     });
130 |   }
131 | }
132 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Composable for managing user preference for opening project directory.
  3 |  * Stores the default target (vscode/terminal) in chrome.storage.local.
  4 |  */
  5 | import { ref, type Ref } from 'vue';
  6 | import type { OpenProjectTarget, OpenProjectResponse } from 'chrome-mcp-shared';
  7 | 
  8 | // Storage key for default open target
  9 | const STORAGE_KEY = 'agent-open-project-default';
 10 | 
 11 | export interface UseOpenProjectPreferenceOptions {
 12 |   /**
 13 |    * Server port for API calls.
 14 |    * Should be provided from useAgentServer.
 15 |    */
 16 |   getServerPort: () => number | null;
 17 | }
 18 | 
 19 | export interface UseOpenProjectPreference {
 20 |   /** Current default target (null if not set) */
 21 |   defaultTarget: Ref<OpenProjectTarget | null>;
 22 |   /** Loading state */
 23 |   loading: Ref<boolean>;
 24 |   /** Load default target from storage */
 25 |   loadDefaultTarget: () => Promise<void>;
 26 |   /** Save default target to storage */
 27 |   saveDefaultTarget: (target: OpenProjectTarget) => Promise<void>;
 28 |   /** Open project by session ID */
 29 |   openBySession: (sessionId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;
 30 |   /** Open project by project ID */
 31 |   openByProject: (projectId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;
 32 | }
 33 | 
 34 | export function useOpenProjectPreference(
 35 |   options: UseOpenProjectPreferenceOptions,
 36 | ): UseOpenProjectPreference {
 37 |   const defaultTarget = ref<OpenProjectTarget | null>(null);
 38 |   const loading = ref(false);
 39 | 
 40 |   /**
 41 |    * Load default target from chrome.storage.local.
 42 |    */
 43 |   async function loadDefaultTarget(): Promise<void> {
 44 |     try {
 45 |       const result = await chrome.storage.local.get(STORAGE_KEY);
 46 |       const stored = result[STORAGE_KEY];
 47 |       if (stored === 'vscode' || stored === 'terminal') {
 48 |         defaultTarget.value = stored;
 49 |       }
 50 |     } catch (error) {
 51 |       console.error('[OpenProjectPreference] Failed to load default target:', error);
 52 |     }
 53 |   }
 54 | 
 55 |   /**
 56 |    * Save default target to chrome.storage.local.
 57 |    */
 58 |   async function saveDefaultTarget(target: OpenProjectTarget): Promise<void> {
 59 |     try {
 60 |       await chrome.storage.local.set({ [STORAGE_KEY]: target });
 61 |       defaultTarget.value = target;
 62 |     } catch (error) {
 63 |       console.error('[OpenProjectPreference] Failed to save default target:', error);
 64 |     }
 65 |   }
 66 | 
 67 |   /**
 68 |    * Open project directory by session ID.
 69 |    */
 70 |   async function openBySession(
 71 |     sessionId: string,
 72 |     target: OpenProjectTarget,
 73 |   ): Promise<OpenProjectResponse> {
 74 |     const port = options.getServerPort();
 75 |     if (!port) {
 76 |       return { success: false, error: 'Server not connected' };
 77 |     }
 78 | 
 79 |     loading.value = true;
 80 |     try {
 81 |       const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}/open`;
 82 |       const response = await fetch(url, {
 83 |         method: 'POST',
 84 |         headers: { 'Content-Type': 'application/json' },
 85 |         body: JSON.stringify({ target }),
 86 |       });
 87 | 
 88 |       const data = (await response.json()) as OpenProjectResponse;
 89 |       return data;
 90 |     } catch (error) {
 91 |       const message = error instanceof Error ? error.message : String(error);
 92 |       return { success: false, error: message };
 93 |     } finally {
 94 |       loading.value = false;
 95 |     }
 96 |   }
 97 | 
 98 |   /**
 99 |    * Open project directory by project ID.
100 |    */
101 |   async function openByProject(
102 |     projectId: string,
103 |     target: OpenProjectTarget,
104 |   ): Promise<OpenProjectResponse> {
105 |     const port = options.getServerPort();
106 |     if (!port) {
107 |       return { success: false, error: 'Server not connected' };
108 |     }
109 | 
110 |     loading.value = true;
111 |     try {
112 |       const url = `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open`;
113 |       const response = await fetch(url, {
114 |         method: 'POST',
115 |         headers: { 'Content-Type': 'application/json' },
116 |         body: JSON.stringify({ target }),
117 |       });
118 | 
119 |       const data = (await response.json()) as OpenProjectResponse;
120 |       return data;
121 |     } catch (error) {
122 |       const message = error instanceof Error ? error.message : String(error);
123 |       return { success: false, error: message };
124 |     } finally {
125 |       loading.value = false;
126 |     }
127 |   }
128 | 
129 |   return {
130 |     defaultTarget,
131 |     loading,
132 |     loadDefaultTarget,
133 |     saveDefaultTarget,
134 |     openBySession,
135 |     openByProject,
136 |   };
137 | }
138 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/download.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
  2 | import { BaseBrowserToolExecutor } from '../base-browser';
  3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
  4 | 
  5 | interface HandleDownloadParams {
  6 |   filenameContains?: string;
  7 |   timeoutMs?: number; // default 60000
  8 |   waitForComplete?: boolean; // default true
  9 | }
 10 | 
 11 | /**
 12 |  * Tool: wait for a download and return info
 13 |  */
 14 | class HandleDownloadTool extends BaseBrowserToolExecutor {
 15 |   name = TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD as any;
 16 | 
 17 |   async execute(args: HandleDownloadParams): Promise<ToolResult> {
 18 |     const filenameContains = String(args?.filenameContains || '').trim();
 19 |     const waitForComplete = args?.waitForComplete !== false;
 20 |     const timeoutMs = Math.max(1000, Math.min(Number(args?.timeoutMs ?? 60000), 300000));
 21 | 
 22 |     try {
 23 |       const result = await waitForDownload({ filenameContains, waitForComplete, timeoutMs });
 24 |       return {
 25 |         content: [{ type: 'text', text: JSON.stringify({ success: true, download: result }) }],
 26 |         isError: false,
 27 |       };
 28 |     } catch (e: any) {
 29 |       return createErrorResponse(`Handle download failed: ${e?.message || String(e)}`);
 30 |     }
 31 |   }
 32 | }
 33 | 
 34 | async function waitForDownload(opts: {
 35 |   filenameContains?: string;
 36 |   waitForComplete: boolean;
 37 |   timeoutMs: number;
 38 | }) {
 39 |   const { filenameContains, waitForComplete, timeoutMs } = opts;
 40 |   return new Promise<any>((resolve, reject) => {
 41 |     let timer: any = null;
 42 |     const onError = (err: any) => {
 43 |       cleanup();
 44 |       reject(err instanceof Error ? err : new Error(String(err)));
 45 |     };
 46 |     const cleanup = () => {
 47 |       try {
 48 |         if (timer) clearTimeout(timer);
 49 |       } catch {}
 50 |       try {
 51 |         chrome.downloads.onCreated.removeListener(onCreated);
 52 |       } catch {}
 53 |       try {
 54 |         chrome.downloads.onChanged.removeListener(onChanged);
 55 |       } catch {}
 56 |     };
 57 |     const matches = (item: chrome.downloads.DownloadItem) => {
 58 |       if (!filenameContains) return true;
 59 |       const name = (item.filename || '').split(/[/\\]/).pop() || '';
 60 |       return name.includes(filenameContains) || (item.url || '').includes(filenameContains);
 61 |     };
 62 |     const fulfill = async (item: chrome.downloads.DownloadItem) => {
 63 |       // try to fill more details via downloads.search
 64 |       try {
 65 |         const [found] = await chrome.downloads.search({ id: item.id });
 66 |         const out = found || item;
 67 |         cleanup();
 68 |         resolve({
 69 |           id: out.id,
 70 |           filename: out.filename,
 71 |           url: out.url,
 72 |           mime: (out as any).mime || undefined,
 73 |           fileSize: out.fileSize ?? out.totalBytes ?? undefined,
 74 |           state: out.state,
 75 |           danger: out.danger,
 76 |           startTime: out.startTime,
 77 |           endTime: (out as any).endTime || undefined,
 78 |           exists: (out as any).exists,
 79 |         });
 80 |         return;
 81 |       } catch {
 82 |         cleanup();
 83 |         resolve({ id: item.id, filename: item.filename, url: item.url, state: item.state });
 84 |       }
 85 |     };
 86 |     const onCreated = (item: chrome.downloads.DownloadItem) => {
 87 |       try {
 88 |         if (!matches(item)) return;
 89 |         if (!waitForComplete) {
 90 |           fulfill(item);
 91 |         }
 92 |       } catch {}
 93 |     };
 94 |     const onChanged = (delta: chrome.downloads.DownloadDelta) => {
 95 |       try {
 96 |         if (!delta || typeof delta.id !== 'number') return;
 97 |         // pull item and check
 98 |         chrome.downloads
 99 |           .search({ id: delta.id })
100 |           .then((arr) => {
101 |             const item = arr && arr[0];
102 |             if (!item) return;
103 |             if (!matches(item)) return;
104 |             if (waitForComplete && item.state === 'complete') fulfill(item);
105 |           })
106 |           .catch(() => {});
107 |       } catch {}
108 |     };
109 |     chrome.downloads.onCreated.addListener(onCreated);
110 |     chrome.downloads.onChanged.addListener(onChanged);
111 |     timer = setTimeout(() => onError(new Error('Download wait timed out')), timeoutMs);
112 |     // Try to find an already-running matching download
113 |     chrome.downloads
114 |       .search({ state: waitForComplete ? 'in_progress' : undefined })
115 |       .then((arr) => {
116 |         const hit = (arr || []).find((d) => matches(d));
117 |         if (hit && !waitForComplete) fulfill(hit);
118 |       })
119 |       .catch(() => {});
120 |   });
121 | }
122 | 
123 | export const handleDownloadTool = new HandleDownloadTool();
124 | 
```

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

```vue
  1 | <template>
  2 |   <div class="flex flex-col gap-2">
  3 |     <!-- Root override -->
  4 |     <div class="flex items-center gap-2">
  5 |       <span class="whitespace-nowrap">Root override</span>
  6 |       <input
  7 |         :value="projectRoot"
  8 |         class="flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
  9 |         placeholder="Optional override path; defaults to selected project workspace"
 10 |         @input="$emit('update:project-root', ($event.target as HTMLInputElement).value)"
 11 |         @change="$emit('save-root')"
 12 |       />
 13 |       <button
 14 |         class="btn-secondary !px-2 !py-1 text-[11px]"
 15 |         type="button"
 16 |         :disabled="isSavingRoot"
 17 |         @click="$emit('save-root')"
 18 |       >
 19 |         {{ isSavingRoot ? 'Saving...' : 'Save' }}
 20 |       </button>
 21 |     </div>
 22 | 
 23 |     <!-- CLI & Model selection -->
 24 |     <div class="flex items-center gap-2">
 25 |       <span class="whitespace-nowrap">CLI</span>
 26 |       <select
 27 |         :value="selectedCli"
 28 |         class="border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
 29 |         @change="handleCliChange"
 30 |       >
 31 |         <option value="">Auto (per project / server default)</option>
 32 |         <option v-for="e in engines" :key="e.name" :value="e.name">
 33 |           {{ e.name }}
 34 |         </option>
 35 |       </select>
 36 |       <span class="whitespace-nowrap">Model</span>
 37 |       <select
 38 |         :value="model"
 39 |         class="flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400"
 40 |         @change="$emit('update:model', ($event.target as HTMLSelectElement).value)"
 41 |       >
 42 |         <option value="">Default</option>
 43 |         <option v-for="m in availableModels" :key="m.id" :value="m.id">
 44 |           {{ m.name }}
 45 |         </option>
 46 |       </select>
 47 |       <!-- CCR option (Claude Code Router) - only shown when Claude CLI is selected -->
 48 |       <label
 49 |         v-if="showCcrOption"
 50 |         class="flex items-center gap-1 whitespace-nowrap cursor-pointer"
 51 |         title="Use Claude Code Router for API routing"
 52 |       >
 53 |         <input
 54 |           type="checkbox"
 55 |           :checked="useCcr"
 56 |           class="w-3 h-3 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
 57 |           @change="$emit('update:use-ccr', ($event.target as HTMLInputElement).checked)"
 58 |         />
 59 |         <span class="text-[11px] text-slate-600">CCR</span>
 60 |       </label>
 61 |       <button
 62 |         class="btn-secondary !px-2 !py-1 text-[11px]"
 63 |         type="button"
 64 |         :disabled="!selectedProject || isSavingPreference"
 65 |         @click="$emit('save-preference')"
 66 |       >
 67 |         {{ isSavingPreference ? 'Saving...' : 'Save' }}
 68 |       </button>
 69 |     </div>
 70 |   </div>
 71 | </template>
 72 | 
 73 | <script lang="ts" setup>
 74 | import { computed } from 'vue';
 75 | import type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';
 76 | import {
 77 |   getModelsForCli,
 78 |   getDefaultModelForCli,
 79 |   type ModelDefinition,
 80 | } from '@/common/agent-models';
 81 | 
 82 | const props = defineProps<{
 83 |   projectRoot: string;
 84 |   selectedCli: string;
 85 |   model: string;
 86 |   useCcr: boolean;
 87 |   engines: AgentEngineInfo[];
 88 |   selectedProject: AgentProject | null;
 89 |   isSavingRoot: boolean;
 90 |   isSavingPreference: boolean;
 91 | }>();
 92 | 
 93 | const emit = defineEmits<{
 94 |   'update:project-root': [value: string];
 95 |   'update:selected-cli': [value: string];
 96 |   'update:model': [value: string];
 97 |   'update:use-ccr': [value: boolean];
 98 |   'save-root': [];
 99 |   'save-preference': [];
100 | }>();
101 | 
102 | // Get available models based on selected CLI
103 | const availableModels = computed<ModelDefinition[]>(() => {
104 |   return getModelsForCli(props.selectedCli);
105 | });
106 | 
107 | // Show CCR option only when Claude CLI is selected
108 | const showCcrOption = computed(() => {
109 |   return props.selectedCli === 'claude';
110 | });
111 | 
112 | // Handle CLI change - auto-select default model for the CLI
113 | function handleCliChange(event: Event): void {
114 |   const cli = (event.target as HTMLSelectElement).value;
115 |   emit('update:selected-cli', cli);
116 | 
117 |   // Auto-select default model when CLI changes
118 |   if (cli) {
119 |     const defaultModel = getDefaultModelForCli(cli);
120 |     emit('update:model', defaultModel);
121 |   } else {
122 |     emit('update:model', '');
123 |   }
124 | 
125 |   // Reset CCR when switching away from Claude
126 |   if (cli !== 'claude') {
127 |     emit('update:use-ccr', false);
128 |   }
129 | }
130 | </script>
131 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview Command Trigger Handler (P4-04)
  3 |  * @description
  4 |  * Listens to `chrome.commands.onCommand` and fires installed command triggers.
  5 |  *
  6 |  * Command triggers allow users to execute flows via keyboard shortcuts
  7 |  * defined in the extension's manifest.
  8 |  *
  9 |  * Design notes:
 10 |  * - Commands must be registered in manifest.json under the "commands" key
 11 |  * - Each command is identified by its commandKey (e.g., "run-flow-1")
 12 |  * - Active tab info is captured when available
 13 |  */
 14 | 
 15 | import type { TriggerId } from '../../domain/ids';
 16 | import type { TriggerSpecByKind } from '../../domain/triggers';
 17 | import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
 18 | 
 19 | // ==================== Types ====================
 20 | 
 21 | export interface CommandTriggerHandlerDeps {
 22 |   logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
 23 | }
 24 | 
 25 | type CommandTriggerSpec = TriggerSpecByKind<'command'>;
 26 | 
 27 | interface InstalledCommandTrigger {
 28 |   spec: CommandTriggerSpec;
 29 | }
 30 | 
 31 | // ==================== Handler Implementation ====================
 32 | 
 33 | /**
 34 |  * Create command trigger handler factory
 35 |  */
 36 | export function createCommandTriggerHandlerFactory(
 37 |   deps?: CommandTriggerHandlerDeps,
 38 | ): TriggerHandlerFactory<'command'> {
 39 |   return (fireCallback) => createCommandTriggerHandler(fireCallback, deps);
 40 | }
 41 | 
 42 | /**
 43 |  * Create command trigger handler
 44 |  */
 45 | export function createCommandTriggerHandler(
 46 |   fireCallback: TriggerFireCallback,
 47 |   deps?: CommandTriggerHandlerDeps,
 48 | ): TriggerHandler<'command'> {
 49 |   const logger = deps?.logger ?? console;
 50 | 
 51 |   // Map commandKey -> triggerId for fast lookup
 52 |   const commandKeyToTriggerId = new Map<string, TriggerId>();
 53 |   const installed = new Map<TriggerId, InstalledCommandTrigger>();
 54 |   let listening = false;
 55 | 
 56 |   /**
 57 |    * Handle chrome.commands.onCommand event
 58 |    */
 59 |   const onCommand = (command: string, tab?: chrome.tabs.Tab): void => {
 60 |     const triggerId = commandKeyToTriggerId.get(command);
 61 |     if (!triggerId) return;
 62 | 
 63 |     const trigger = installed.get(triggerId);
 64 |     if (!trigger) return;
 65 | 
 66 |     // Fire and forget: chrome event listeners should not block
 67 |     Promise.resolve(
 68 |       fireCallback.onFire(triggerId, {
 69 |         sourceTabId: tab?.id,
 70 |         sourceUrl: tab?.url,
 71 |       }),
 72 |     ).catch((e) => {
 73 |       logger.error(`[CommandTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
 74 |     });
 75 |   };
 76 | 
 77 |   /**
 78 |    * Ensure listener is registered
 79 |    */
 80 |   function ensureListening(): void {
 81 |     if (listening) return;
 82 |     if (!chrome.commands?.onCommand?.addListener) {
 83 |       logger.warn('[CommandTriggerHandler] chrome.commands.onCommand is unavailable');
 84 |       return;
 85 |     }
 86 |     chrome.commands.onCommand.addListener(onCommand);
 87 |     listening = true;
 88 |   }
 89 | 
 90 |   /**
 91 |    * Stop listening
 92 |    */
 93 |   function stopListening(): void {
 94 |     if (!listening) return;
 95 |     try {
 96 |       chrome.commands.onCommand.removeListener(onCommand);
 97 |     } catch (e) {
 98 |       logger.debug('[CommandTriggerHandler] removeListener failed:', e);
 99 |     } finally {
100 |       listening = false;
101 |     }
102 |   }
103 | 
104 |   return {
105 |     kind: 'command',
106 | 
107 |     async install(trigger: CommandTriggerSpec): Promise<void> {
108 |       const { id, commandKey } = trigger;
109 | 
110 |       // Warn if commandKey already used by another trigger
111 |       const existingTriggerId = commandKeyToTriggerId.get(commandKey);
112 |       if (existingTriggerId && existingTriggerId !== id) {
113 |         logger.warn(
114 |           `[CommandTriggerHandler] Command "${commandKey}" already used by trigger "${existingTriggerId}", overwriting with "${id}"`,
115 |         );
116 |         // Remove old mapping
117 |         installed.delete(existingTriggerId);
118 |       }
119 | 
120 |       installed.set(id, { spec: trigger });
121 |       commandKeyToTriggerId.set(commandKey, id);
122 |       ensureListening();
123 |     },
124 | 
125 |     async uninstall(triggerId: string): Promise<void> {
126 |       const trigger = installed.get(triggerId as TriggerId);
127 |       if (trigger) {
128 |         commandKeyToTriggerId.delete(trigger.spec.commandKey);
129 |         installed.delete(triggerId as TriggerId);
130 |       }
131 | 
132 |       if (installed.size === 0) {
133 |         stopListening();
134 |       }
135 |     },
136 | 
137 |     async uninstallAll(): Promise<void> {
138 |       installed.clear();
139 |       commandKeyToTriggerId.clear();
140 |       stopListening();
141 |     },
142 | 
143 |     getInstalledIds(): string[] {
144 |       return Array.from(installed.keys());
145 |     },
146 |   };
147 | }
148 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Composable for textarea auto-resize functionality.
  3 |  * Automatically adjusts textarea height based on content while respecting min/max constraints.
  4 |  */
  5 | import { ref, watch, nextTick, onMounted, onUnmounted, type Ref } from 'vue';
  6 | 
  7 | export interface UseTextareaAutoResizeOptions {
  8 |   /** Ref to the textarea element */
  9 |   textareaRef: Ref<HTMLTextAreaElement | null>;
 10 |   /** Ref to the textarea value (for watching changes) */
 11 |   value: Ref<string>;
 12 |   /** Minimum height in pixels */
 13 |   minHeight?: number;
 14 |   /** Maximum height in pixels */
 15 |   maxHeight?: number;
 16 | }
 17 | 
 18 | export interface UseTextareaAutoResizeReturn {
 19 |   /** Current calculated height */
 20 |   height: Ref<number>;
 21 |   /** Whether content exceeds max height (textarea is overflowing) */
 22 |   isOverflowing: Ref<boolean>;
 23 |   /** Manually trigger height recalculation */
 24 |   recalculate: () => void;
 25 | }
 26 | 
 27 | const DEFAULT_MIN_HEIGHT = 50;
 28 | const DEFAULT_MAX_HEIGHT = 200;
 29 | 
 30 | /**
 31 |  * Composable for auto-resizing textarea based on content.
 32 |  *
 33 |  * Features:
 34 |  * - Automatically adjusts height on input
 35 |  * - Respects min/max height constraints
 36 |  * - Handles width changes (line wrapping affects height)
 37 |  * - Uses requestAnimationFrame for performance
 38 |  */
 39 | export function useTextareaAutoResize(
 40 |   options: UseTextareaAutoResizeOptions,
 41 | ): UseTextareaAutoResizeReturn {
 42 |   const {
 43 |     textareaRef,
 44 |     value,
 45 |     minHeight = DEFAULT_MIN_HEIGHT,
 46 |     maxHeight = DEFAULT_MAX_HEIGHT,
 47 |   } = options;
 48 | 
 49 |   const height = ref<number>(minHeight);
 50 |   const isOverflowing = ref(false);
 51 | 
 52 |   let scheduled = false;
 53 |   let resizeObserver: ResizeObserver | null = null;
 54 |   let lastWidth = 0;
 55 | 
 56 |   /**
 57 |    * Calculate textarea height based on content.
 58 |    * Only updates the reactive `height` and `isOverflowing` refs.
 59 |    * The actual DOM height is controlled via :style binding in the template.
 60 |    */
 61 |   function recalculate(): void {
 62 |     const el = textareaRef.value;
 63 |     if (!el) return;
 64 | 
 65 |     // Temporarily set height to 'auto' to get accurate scrollHeight
 66 |     // Save current height to minimize visual flicker
 67 |     const currentHeight = el.style.height;
 68 |     el.style.height = 'auto';
 69 | 
 70 |     const contentHeight = el.scrollHeight;
 71 |     const clampedHeight = Math.min(maxHeight, Math.max(minHeight, contentHeight));
 72 | 
 73 |     // Restore height immediately (the actual height is controlled by Vue binding)
 74 |     el.style.height = currentHeight;
 75 | 
 76 |     // Update reactive state
 77 |     height.value = clampedHeight;
 78 |     // Add small tolerance (1px) to account for rounding
 79 |     isOverflowing.value = contentHeight > maxHeight + 1;
 80 |   }
 81 | 
 82 |   /**
 83 |    * Schedule height recalculation using requestAnimationFrame.
 84 |    * Batches multiple calls within the same frame for performance.
 85 |    */
 86 |   function scheduleRecalculate(): void {
 87 |     if (scheduled) return;
 88 |     scheduled = true;
 89 |     requestAnimationFrame(() => {
 90 |       scheduled = false;
 91 |       recalculate();
 92 |     });
 93 |   }
 94 | 
 95 |   // Watch value changes
 96 |   watch(
 97 |     value,
 98 |     async () => {
 99 |       await nextTick();
100 |       scheduleRecalculate();
101 |     },
102 |     { flush: 'post' },
103 |   );
104 | 
105 |   // Watch textarea ref changes (in case it's replaced)
106 |   watch(
107 |     textareaRef,
108 |     async (newEl, oldEl) => {
109 |       // Cleanup old observer
110 |       if (resizeObserver && oldEl) {
111 |         resizeObserver.unobserve(oldEl);
112 |       }
113 | 
114 |       if (!newEl) return;
115 | 
116 |       await nextTick();
117 |       scheduleRecalculate();
118 | 
119 |       // Setup new observer for width changes
120 |       if (resizeObserver) {
121 |         lastWidth = newEl.offsetWidth;
122 |         resizeObserver.observe(newEl);
123 |       }
124 |     },
125 |     { immediate: true },
126 |   );
127 | 
128 |   onMounted(() => {
129 |     const el = textareaRef.value;
130 |     if (!el) return;
131 | 
132 |     // Initial calculation
133 |     scheduleRecalculate();
134 | 
135 |     // Setup ResizeObserver for width changes
136 |     // Width changes affect line wrapping, which affects scrollHeight
137 |     if (typeof ResizeObserver !== 'undefined') {
138 |       lastWidth = el.offsetWidth;
139 |       resizeObserver = new ResizeObserver(() => {
140 |         const current = textareaRef.value;
141 |         if (!current) return;
142 | 
143 |         const currentWidth = current.offsetWidth;
144 |         if (currentWidth !== lastWidth) {
145 |           lastWidth = currentWidth;
146 |           scheduleRecalculate();
147 |         }
148 |       });
149 |       resizeObserver.observe(el);
150 |     }
151 |   });
152 | 
153 |   onUnmounted(() => {
154 |     resizeObserver?.disconnect();
155 |     resizeObserver = null;
156 |   });
157 | 
158 |   return {
159 |     height,
160 |     isOverflowing,
161 |     recalculate,
162 |   };
163 | }
164 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Composable for managing AgentChat theme.
  3 |  * Handles theme persistence and application.
  4 |  */
  5 | import { ref, type Ref } from 'vue';
  6 | 
  7 | /** Available theme identifiers */
  8 | export type AgentThemeId =
  9 |   | 'warm-editorial'
 10 |   | 'blueprint-architect'
 11 |   | 'zen-journal'
 12 |   | 'neo-pop'
 13 |   | 'dark-console'
 14 |   | 'swiss-grid';
 15 | 
 16 | /** Storage key for persisting theme preference */
 17 | const STORAGE_KEY_THEME = 'agentTheme';
 18 | 
 19 | /** Default theme when none is set */
 20 | const DEFAULT_THEME: AgentThemeId = 'warm-editorial';
 21 | 
 22 | /** Valid theme IDs for validation */
 23 | const VALID_THEMES: AgentThemeId[] = [
 24 |   'warm-editorial',
 25 |   'blueprint-architect',
 26 |   'zen-journal',
 27 |   'neo-pop',
 28 |   'dark-console',
 29 |   'swiss-grid',
 30 | ];
 31 | 
 32 | /** Theme display names for UI */
 33 | export const THEME_LABELS: Record<AgentThemeId, string> = {
 34 |   'warm-editorial': 'Editorial',
 35 |   'blueprint-architect': 'Blueprint',
 36 |   'zen-journal': 'Zen',
 37 |   'neo-pop': 'Neo-Pop',
 38 |   'dark-console': 'Console',
 39 |   'swiss-grid': 'Swiss',
 40 | };
 41 | 
 42 | export interface UseAgentTheme {
 43 |   /** Current theme ID */
 44 |   theme: Ref<AgentThemeId>;
 45 |   /** Whether theme has been loaded from storage */
 46 |   ready: Ref<boolean>;
 47 |   /** Set and persist a new theme */
 48 |   setTheme: (id: AgentThemeId) => Promise<void>;
 49 |   /** Load theme from storage (call on mount) */
 50 |   initTheme: () => Promise<void>;
 51 |   /** Apply theme to a DOM element */
 52 |   applyTo: (el: HTMLElement) => void;
 53 |   /** Get the preloaded theme from document (set by main.ts) */
 54 |   getPreloadedTheme: () => AgentThemeId;
 55 | }
 56 | 
 57 | /**
 58 |  * Check if a string is a valid theme ID
 59 |  */
 60 | function isValidTheme(value: unknown): value is AgentThemeId {
 61 |   return typeof value === 'string' && VALID_THEMES.includes(value as AgentThemeId);
 62 | }
 63 | 
 64 | /**
 65 |  * Get theme from document element (preloaded by main.ts)
 66 |  */
 67 | function getThemeFromDocument(): AgentThemeId {
 68 |   const value = document.documentElement.dataset.agentTheme;
 69 |   return isValidTheme(value) ? value : DEFAULT_THEME;
 70 | }
 71 | 
 72 | /**
 73 |  * Composable for managing AgentChat theme
 74 |  */
 75 | export function useAgentTheme(): UseAgentTheme {
 76 |   // Initialize with preloaded theme (or default)
 77 |   const theme = ref<AgentThemeId>(getThemeFromDocument());
 78 |   const ready = ref(false);
 79 | 
 80 |   /**
 81 |    * Load theme from chrome.storage.local
 82 |    */
 83 |   async function initTheme(): Promise<void> {
 84 |     try {
 85 |       const result = await chrome.storage.local.get(STORAGE_KEY_THEME);
 86 |       const stored = result[STORAGE_KEY_THEME];
 87 | 
 88 |       if (isValidTheme(stored)) {
 89 |         theme.value = stored;
 90 |       } else {
 91 |         // Use preloaded or default
 92 |         theme.value = getThemeFromDocument();
 93 |       }
 94 |     } catch (error) {
 95 |       console.error('[useAgentTheme] Failed to load theme:', error);
 96 |       theme.value = getThemeFromDocument();
 97 |     } finally {
 98 |       ready.value = true;
 99 |     }
100 |   }
101 | 
102 |   /**
103 |    * Set and persist a new theme
104 |    */
105 |   async function setTheme(id: AgentThemeId): Promise<void> {
106 |     if (!isValidTheme(id)) {
107 |       console.warn('[useAgentTheme] Invalid theme ID:', id);
108 |       return;
109 |     }
110 | 
111 |     // Update immediately for responsive UI
112 |     theme.value = id;
113 | 
114 |     // Also update document element for consistency
115 |     document.documentElement.dataset.agentTheme = id;
116 | 
117 |     // Persist to storage
118 |     try {
119 |       await chrome.storage.local.set({ [STORAGE_KEY_THEME]: id });
120 |     } catch (error) {
121 |       console.error('[useAgentTheme] Failed to save theme:', error);
122 |     }
123 |   }
124 | 
125 |   /**
126 |    * Apply theme to a DOM element
127 |    */
128 |   function applyTo(el: HTMLElement): void {
129 |     el.dataset.agentTheme = theme.value;
130 |   }
131 | 
132 |   /**
133 |    * Get the preloaded theme from document
134 |    */
135 |   function getPreloadedTheme(): AgentThemeId {
136 |     return getThemeFromDocument();
137 |   }
138 | 
139 |   return {
140 |     theme,
141 |     ready,
142 |     setTheme,
143 |     initTheme,
144 |     applyTo,
145 |     getPreloadedTheme,
146 |   };
147 | }
148 | 
149 | /**
150 |  * Preload theme before Vue mounts (call in main.ts)
151 |  * This prevents theme flashing on page load.
152 |  */
153 | export async function preloadAgentTheme(): Promise<AgentThemeId> {
154 |   let themeId: AgentThemeId = DEFAULT_THEME;
155 | 
156 |   try {
157 |     const result = await chrome.storage.local.get(STORAGE_KEY_THEME);
158 |     const stored = result[STORAGE_KEY_THEME];
159 | 
160 |     if (isValidTheme(stored)) {
161 |       themeId = stored;
162 |     }
163 |   } catch (error) {
164 |     console.error('[preloadAgentTheme] Failed to load theme:', error);
165 |   }
166 | 
167 |   // Set on document element for immediate application
168 |   document.documentElement.dataset.agentTheme = themeId;
169 | 
170 |   return themeId;
171 | }
172 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay-v3/manual-trigger.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview Manual Trigger Handler 测试 (P4-08)
  3 |  * @description
  4 |  * Tests for:
  5 |  * - Basic install/uninstall operations
  6 |  * - getInstalledIds tracking
  7 |  */
  8 | 
  9 | import { describe, expect, it, vi } from 'vitest';
 10 | 
 11 | import type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';
 12 | import type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';
 13 | import { createManualTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger';
 14 | 
 15 | // ==================== Test Utilities ====================
 16 | 
 17 | function createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {
 18 |   return {
 19 |     debug: () => {},
 20 |     info: () => {},
 21 |     warn: () => {},
 22 |     error: () => {},
 23 |   };
 24 | }
 25 | 
 26 | // ==================== Manual Trigger Tests ====================
 27 | 
 28 | describe('V3 ManualTriggerHandler', () => {
 29 |   describe('Installation', () => {
 30 |     it('installs trigger', async () => {
 31 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
 32 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
 33 |         fireCallback,
 34 |       );
 35 | 
 36 |       const trigger: TriggerSpecByKind<'manual'> = {
 37 |         id: 't1' as never,
 38 |         kind: 'manual',
 39 |         enabled: true,
 40 |         flowId: 'flow-1' as never,
 41 |       };
 42 | 
 43 |       await handler.install(trigger);
 44 | 
 45 |       expect(handler.getInstalledIds()).toEqual(['t1']);
 46 |     });
 47 | 
 48 |     it('installs multiple triggers', async () => {
 49 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
 50 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
 51 |         fireCallback,
 52 |       );
 53 | 
 54 |       await handler.install({
 55 |         id: 't1' as never,
 56 |         kind: 'manual',
 57 |         enabled: true,
 58 |         flowId: 'flow-1' as never,
 59 |       });
 60 | 
 61 |       await handler.install({
 62 |         id: 't2' as never,
 63 |         kind: 'manual',
 64 |         enabled: true,
 65 |         flowId: 'flow-2' as never,
 66 |       });
 67 | 
 68 |       expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);
 69 |     });
 70 |   });
 71 | 
 72 |   describe('Uninstallation', () => {
 73 |     it('uninstalls trigger', async () => {
 74 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
 75 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
 76 |         fireCallback,
 77 |       );
 78 | 
 79 |       await handler.install({
 80 |         id: 't1' as never,
 81 |         kind: 'manual',
 82 |         enabled: true,
 83 |         flowId: 'flow-1' as never,
 84 |       });
 85 | 
 86 |       await handler.uninstall('t1');
 87 | 
 88 |       expect(handler.getInstalledIds()).toEqual([]);
 89 |     });
 90 | 
 91 |     it('uninstallAll clears all triggers', async () => {
 92 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
 93 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
 94 |         fireCallback,
 95 |       );
 96 | 
 97 |       await handler.install({
 98 |         id: 't1' as never,
 99 |         kind: 'manual',
100 |         enabled: true,
101 |         flowId: 'flow-1' as never,
102 |       });
103 | 
104 |       await handler.install({
105 |         id: 't2' as never,
106 |         kind: 'manual',
107 |         enabled: true,
108 |         flowId: 'flow-2' as never,
109 |       });
110 | 
111 |       await handler.uninstallAll();
112 | 
113 |       expect(handler.getInstalledIds()).toEqual([]);
114 |     });
115 |   });
116 | 
117 |   describe('getInstalledIds', () => {
118 |     it('returns empty array when no triggers installed', async () => {
119 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
120 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
121 |         fireCallback,
122 |       );
123 | 
124 |       expect(handler.getInstalledIds()).toEqual([]);
125 |     });
126 | 
127 |     it('tracks partial uninstall', async () => {
128 |       const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };
129 |       const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(
130 |         fireCallback,
131 |       );
132 | 
133 |       await handler.install({
134 |         id: 't1' as never,
135 |         kind: 'manual',
136 |         enabled: true,
137 |         flowId: 'flow-1' as never,
138 |       });
139 | 
140 |       await handler.install({
141 |         id: 't2' as never,
142 |         kind: 'manual',
143 |         enabled: true,
144 |         flowId: 'flow-2' as never,
145 |       });
146 | 
147 |       await handler.uninstall('t1');
148 | 
149 |       expect(handler.getInstalledIds()).toEqual(['t2']);
150 |     });
151 |   });
152 | });
153 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/agent/directory-picker.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Directory Picker Service.
  3 |  *
  4 |  * Provides cross-platform directory selection using native system dialogs.
  5 |  * Uses platform-specific commands:
  6 |  * - macOS: osascript (AppleScript)
  7 |  * - Windows: PowerShell
  8 |  * - Linux: zenity or kdialog
  9 |  */
 10 | import { exec } from 'node:child_process';
 11 | import { promisify } from 'node:util';
 12 | import os from 'node:os';
 13 | 
 14 | const execAsync = promisify(exec);
 15 | 
 16 | export interface DirectoryPickerResult {
 17 |   success: boolean;
 18 |   path?: string;
 19 |   cancelled?: boolean;
 20 |   error?: string;
 21 | }
 22 | 
 23 | /**
 24 |  * Open a native directory picker dialog.
 25 |  * Returns the selected directory path or indicates cancellation.
 26 |  */
 27 | export async function openDirectoryPicker(
 28 |   title = 'Select Project Directory',
 29 | ): Promise<DirectoryPickerResult> {
 30 |   const platform = os.platform();
 31 | 
 32 |   try {
 33 |     switch (platform) {
 34 |       case 'darwin':
 35 |         return await openMacOSPicker(title);
 36 |       case 'win32':
 37 |         return await openWindowsPicker(title);
 38 |       case 'linux':
 39 |         return await openLinuxPicker(title);
 40 |       default:
 41 |         return {
 42 |           success: false,
 43 |           error: `Unsupported platform: ${platform}`,
 44 |         };
 45 |     }
 46 |   } catch (error) {
 47 |     return {
 48 |       success: false,
 49 |       error: error instanceof Error ? error.message : String(error),
 50 |     };
 51 |   }
 52 | }
 53 | 
 54 | /**
 55 |  * macOS: Use osascript to open Finder folder picker.
 56 |  */
 57 | async function openMacOSPicker(title: string): Promise<DirectoryPickerResult> {
 58 |   const script = `
 59 |     set selectedFolder to choose folder with prompt "${title}"
 60 |     return POSIX path of selectedFolder
 61 |   `;
 62 | 
 63 |   try {
 64 |     const { stdout } = await execAsync(`osascript -e '${script}'`);
 65 |     const path = stdout.trim();
 66 |     if (path) {
 67 |       return { success: true, path };
 68 |     }
 69 |     return { success: false, cancelled: true };
 70 |   } catch (error) {
 71 |     // User cancelled returns error code 1
 72 |     const err = error as { code?: number; stderr?: string };
 73 |     if (err.code === 1) {
 74 |       return { success: false, cancelled: true };
 75 |     }
 76 |     throw error;
 77 |   }
 78 | }
 79 | 
 80 | /**
 81 |  * Windows: Use PowerShell to open folder browser dialog.
 82 |  */
 83 | async function openWindowsPicker(title: string): Promise<DirectoryPickerResult> {
 84 |   const psScript = `
 85 |     Add-Type -AssemblyName System.Windows.Forms
 86 |     $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
 87 |     $dialog.Description = "${title}"
 88 |     $dialog.ShowNewFolderButton = $true
 89 |     $result = $dialog.ShowDialog()
 90 |     if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
 91 |       Write-Output $dialog.SelectedPath
 92 |     }
 93 |   `;
 94 | 
 95 |   // Escape for command line
 96 |   const escapedScript = psScript.replace(/"/g, '\\"').replace(/\n/g, ' ');
 97 | 
 98 |   try {
 99 |     const { stdout } = await execAsync(
100 |       `powershell -NoProfile -Command "${escapedScript}"`,
101 |       { timeout: 60000 }, // 60 second timeout
102 |     );
103 |     const path = stdout.trim();
104 |     if (path) {
105 |       return { success: true, path };
106 |     }
107 |     return { success: false, cancelled: true };
108 |   } catch (error) {
109 |     const err = error as { killed?: boolean };
110 |     if (err.killed) {
111 |       return { success: false, error: 'Dialog timed out' };
112 |     }
113 |     throw error;
114 |   }
115 | }
116 | 
117 | /**
118 |  * Linux: Try zenity first, then kdialog as fallback.
119 |  */
120 | async function openLinuxPicker(title: string): Promise<DirectoryPickerResult> {
121 |   // Try zenity first (GTK)
122 |   try {
123 |     const { stdout } = await execAsync(`zenity --file-selection --directory --title="${title}"`, {
124 |       timeout: 60000,
125 |     });
126 |     const path = stdout.trim();
127 |     if (path) {
128 |       return { success: true, path };
129 |     }
130 |     return { success: false, cancelled: true };
131 |   } catch (zenityError) {
132 |     // zenity returns exit code 1 on cancel, 5 if not installed
133 |     const err = zenityError as { code?: number };
134 |     if (err.code === 1) {
135 |       return { success: false, cancelled: true };
136 |     }
137 | 
138 |     // Try kdialog as fallback (KDE)
139 |     try {
140 |       const { stdout } = await execAsync(`kdialog --getexistingdirectory ~ --title "${title}"`, {
141 |         timeout: 60000,
142 |       });
143 |       const path = stdout.trim();
144 |       if (path) {
145 |         return { success: true, path };
146 |       }
147 |       return { success: false, cancelled: true };
148 |     } catch (kdialogError) {
149 |       const kdErr = kdialogError as { code?: number };
150 |       if (kdErr.code === 1) {
151 |         return { success: false, cancelled: true };
152 |       }
153 | 
154 |       return {
155 |         success: false,
156 |         error: 'No directory picker available. Please install zenity or kdialog.',
157 |       };
158 |     }
159 |   }
160 | }
161 | 
```

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

```vue
  1 | <template>
  2 |   <div
  3 |     ref="chipRef"
  4 |     class="relative inline-flex items-center gap-1.5 text-[11px] leading-none flex-shrink-0 select-none"
  5 |     :style="chipStyle"
  6 |     @mouseenter="handleMouseEnter"
  7 |     @mouseleave="handleMouseLeave"
  8 |   >
  9 |     <!-- Selection Icon -->
 10 |     <span class="inline-flex items-center justify-center w-3.5 h-3.5" :style="iconStyle">
 11 |       <svg
 12 |         class="w-3.5 h-3.5"
 13 |         fill="none"
 14 |         viewBox="0 0 24 24"
 15 |         stroke="currentColor"
 16 |         aria-hidden="true"
 17 |       >
 18 |         <path
 19 |           stroke-linecap="round"
 20 |           stroke-linejoin="round"
 21 |           stroke-width="2"
 22 |           d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
 23 |         />
 24 |       </svg>
 25 |     </span>
 26 | 
 27 |     <!-- Element Label (tagName only) -->
 28 |     <span class="truncate max-w-[140px] px-1 py-0.5" :style="labelStyle">
 29 |       {{ chipTagName }}
 30 |     </span>
 31 | 
 32 |     <!-- "Selected" Indicator -->
 33 |     <span class="px-1 py-0.5 text-[9px] uppercase tracking-wider" :style="pillStyle"> sel </span>
 34 |   </div>
 35 | </template>
 36 | 
 37 | <script lang="ts" setup>
 38 | import { computed, ref, onUnmounted } from 'vue';
 39 | import type { SelectedElementSummary } from '@/common/web-editor-types';
 40 | 
 41 | // =============================================================================
 42 | // Props & Emits
 43 | // =============================================================================
 44 | 
 45 | const props = defineProps<{
 46 |   /** Selected element summary to display */
 47 |   selected: SelectedElementSummary;
 48 | }>();
 49 | 
 50 | const emit = defineEmits<{
 51 |   /** Mouse enter - start highlight */
 52 |   'hover:start': [selected: SelectedElementSummary];
 53 |   /** Mouse leave - clear highlight */
 54 |   'hover:end': [selected: SelectedElementSummary];
 55 | }>();
 56 | 
 57 | // =============================================================================
 58 | // Local State
 59 | // =============================================================================
 60 | 
 61 | const chipRef = ref<HTMLDivElement | null>(null);
 62 | const isHovering = ref(false);
 63 | 
 64 | // =============================================================================
 65 | // Computed: UI State
 66 | // =============================================================================
 67 | 
 68 | /**
 69 |  * Use tagName for compact chip display.
 70 |  * Falls back to extracting from label if tagName is not available.
 71 |  */
 72 | const chipTagName = computed(() => {
 73 |   // First try explicit tagName
 74 |   if (props.selected.tagName) {
 75 |     return props.selected.tagName.toLowerCase();
 76 |   }
 77 |   // Fallback: extract from label
 78 |   const label = (props.selected.label || '').trim();
 79 |   const match = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
 80 |   return match?.[1]?.toLowerCase() || 'element';
 81 | });
 82 | 
 83 | // =============================================================================
 84 | // Computed: Styles
 85 | // =============================================================================
 86 | 
 87 | const chipStyle = computed(() => ({
 88 |   backgroundColor: isHovering.value ? 'var(--ac-hover-bg)' : 'var(--ac-surface)',
 89 |   border: `var(--ac-border-width) solid ${isHovering.value ? 'var(--ac-accent)' : 'var(--ac-border)'}`,
 90 |   borderRadius: 'var(--ac-radius-button)',
 91 |   boxShadow: isHovering.value ? 'var(--ac-shadow-card)' : 'none',
 92 |   color: 'var(--ac-text)',
 93 |   cursor: 'default',
 94 | }));
 95 | 
 96 | const iconStyle = computed(() => ({
 97 |   color: 'var(--ac-accent)',
 98 | }));
 99 | 
100 | const labelStyle = computed(() => ({
101 |   fontFamily: 'var(--ac-font-mono)',
102 | }));
103 | 
104 | const pillStyle = computed(() => ({
105 |   backgroundColor: 'var(--ac-accent)',
106 |   color: 'var(--ac-accent-contrast)',
107 |   borderRadius: 'var(--ac-radius-button)',
108 |   fontFamily: 'var(--ac-font-mono)',
109 |   fontWeight: '600',
110 | }));
111 | 
112 | // =============================================================================
113 | // Event Handlers
114 | // =============================================================================
115 | 
116 | function handleMouseEnter(): void {
117 |   isHovering.value = true;
118 |   emit('hover:start', props.selected);
119 | }
120 | 
121 | function handleMouseLeave(): void {
122 |   isHovering.value = false;
123 |   emit('hover:end', props.selected);
124 | }
125 | 
126 | // =============================================================================
127 | // Lifecycle
128 | // =============================================================================
129 | 
130 | onUnmounted(() => {
131 |   // Clear any active highlight when chip is unmounted
132 |   // (e.g., when selection changes or element appears in edits)
133 |   if (isHovering.value) {
134 |     emit('hover:end', props.selected);
135 |   }
136 | });
137 | </script>
138 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/popup/style.css:
--------------------------------------------------------------------------------

```css
  1 | /* 现代化全局样式 */
  2 | :root {
  3 |   /* 字体系统 */
  4 |   font-family:
  5 |     -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  6 |   line-height: 1.6;
  7 |   font-weight: 400;
  8 | 
  9 |   /* 颜色系统 */
 10 |   --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 11 |   --primary-color: #667eea;
 12 |   --primary-dark: #5a67d8;
 13 |   --secondary-color: #764ba2;
 14 | 
 15 |   --success-color: #48bb78;
 16 |   --warning-color: #ed8936;
 17 |   --error-color: #f56565;
 18 |   --info-color: #4299e1;
 19 | 
 20 |   --text-primary: #2d3748;
 21 |   --text-secondary: #4a5568;
 22 |   --text-muted: #718096;
 23 |   --text-light: #a0aec0;
 24 | 
 25 |   --bg-primary: #ffffff;
 26 |   --bg-secondary: #f7fafc;
 27 |   --bg-tertiary: #edf2f7;
 28 |   --bg-overlay: rgba(255, 255, 255, 0.95);
 29 | 
 30 |   --border-color: #e2e8f0;
 31 |   --border-light: #f1f5f9;
 32 |   --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
 33 |   --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
 34 |   --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
 35 |   --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
 36 | 
 37 |   /* 间距系统 */
 38 |   --spacing-xs: 4px;
 39 |   --spacing-sm: 8px;
 40 |   --spacing-md: 12px;
 41 |   --spacing-lg: 16px;
 42 |   --spacing-xl: 20px;
 43 |   --spacing-2xl: 24px;
 44 |   --spacing-3xl: 32px;
 45 | 
 46 |   /* 圆角系统 */
 47 |   --radius-sm: 4px;
 48 |   --radius-md: 6px;
 49 |   --radius-lg: 8px;
 50 |   --radius-xl: 12px;
 51 |   --radius-2xl: 16px;
 52 | 
 53 |   /* 动画 */
 54 |   --transition-fast: 0.15s ease;
 55 |   --transition-normal: 0.3s ease;
 56 |   --transition-slow: 0.5s ease;
 57 | 
 58 |   /* 字体渲染优化 */
 59 |   font-synthesis: none;
 60 |   text-rendering: optimizeLegibility;
 61 |   -webkit-font-smoothing: antialiased;
 62 |   -moz-osx-font-smoothing: grayscale;
 63 |   -webkit-text-size-adjust: 100%;
 64 | }
 65 | 
 66 | /* 重置样式 */
 67 | * {
 68 |   box-sizing: border-box;
 69 |   margin: 0;
 70 |   padding: 0;
 71 | }
 72 | 
 73 | body {
 74 |   margin: 0;
 75 |   padding: 0;
 76 |   width: 400px;
 77 |   min-height: 500px;
 78 |   max-height: 600px;
 79 |   overflow: hidden;
 80 |   font-family: inherit;
 81 |   background: var(--bg-secondary);
 82 |   color: var(--text-primary);
 83 | }
 84 | 
 85 | #app {
 86 |   width: 100%;
 87 |   height: 100%;
 88 |   margin: 0;
 89 |   padding: 0;
 90 | }
 91 | 
 92 | /* 链接样式 */
 93 | a {
 94 |   color: var(--primary-color);
 95 |   text-decoration: none;
 96 |   transition: color var(--transition-fast);
 97 | }
 98 | 
 99 | a:hover {
100 |   color: var(--primary-dark);
101 | }
102 | 
103 | /* 按钮基础样式重置 */
104 | button {
105 |   font-family: inherit;
106 |   font-size: inherit;
107 |   line-height: inherit;
108 |   border: none;
109 |   background: none;
110 |   cursor: pointer;
111 |   transition: all var(--transition-normal);
112 | }
113 | 
114 | button:disabled {
115 |   cursor: not-allowed;
116 |   opacity: 0.6;
117 | }
118 | 
119 | /* 输入框基础样式 */
120 | input,
121 | textarea,
122 | select {
123 |   font-family: inherit;
124 |   font-size: inherit;
125 |   line-height: inherit;
126 |   border: 1px solid var(--border-color);
127 |   border-radius: var(--radius-md);
128 |   padding: var(--spacing-sm) var(--spacing-md);
129 |   background: var(--bg-primary);
130 |   color: var(--text-primary);
131 |   transition: all var(--transition-fast);
132 | }
133 | 
134 | input:focus,
135 | textarea:focus,
136 | select:focus {
137 |   outline: none;
138 |   border-color: var(--primary-color);
139 |   box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
140 | }
141 | 
142 | /* 滚动条样式 */
143 | ::-webkit-scrollbar {
144 |   width: 8px;
145 |   height: 8px;
146 | }
147 | 
148 | ::-webkit-scrollbar-track {
149 |   background: var(--bg-tertiary);
150 |   border-radius: var(--radius-sm);
151 | }
152 | 
153 | ::-webkit-scrollbar-thumb {
154 |   background: var(--border-color);
155 |   border-radius: var(--radius-sm);
156 |   transition: background var(--transition-fast);
157 | }
158 | 
159 | ::-webkit-scrollbar-thumb:hover {
160 |   background: var(--text-muted);
161 | }
162 | 
163 | /* 选择文本样式 */
164 | ::selection {
165 |   background: rgba(102, 126, 234, 0.2);
166 |   color: var(--text-primary);
167 | }
168 | 
169 | /* 焦点可见性 */
170 | :focus-visible {
171 |   outline: 2px solid var(--primary-color);
172 |   outline-offset: 2px;
173 | }
174 | 
175 | /* 动画关键帧 */
176 | @keyframes fadeIn {
177 |   from {
178 |     opacity: 0;
179 |   }
180 |   to {
181 |     opacity: 1;
182 |   }
183 | }
184 | 
185 | @keyframes slideUp {
186 |   from {
187 |     opacity: 0;
188 |     transform: translateY(10px);
189 |   }
190 |   to {
191 |     opacity: 1;
192 |     transform: translateY(0);
193 |   }
194 | }
195 | 
196 | @keyframes slideDown {
197 |   from {
198 |     opacity: 0;
199 |     transform: translateY(-10px);
200 |   }
201 |   to {
202 |     opacity: 1;
203 |     transform: translateY(0);
204 |   }
205 | }
206 | 
207 | @keyframes scaleIn {
208 |   from {
209 |     opacity: 0;
210 |     transform: scale(0.95);
211 |   }
212 |   to {
213 |     opacity: 1;
214 |     transform: scale(1);
215 |   }
216 | }
217 | 
218 | /* 响应式断点 */
219 | @media (max-width: 420px) {
220 |   :root {
221 |     --spacing-xs: 3px;
222 |     --spacing-sm: 6px;
223 |     --spacing-md: 10px;
224 |     --spacing-lg: 14px;
225 |     --spacing-xl: 18px;
226 |     --spacing-2xl: 22px;
227 |     --spacing-3xl: 28px;
228 |   }
229 | }
230 | 
231 | /* 高对比度模式支持 */
232 | @media (prefers-contrast: high) {
233 |   :root {
234 |     --border-color: #000000;
235 |     --text-muted: #000000;
236 |   }
237 | }
238 | 
239 | /* 减少动画偏好 */
240 | @media (prefers-reduced-motion: reduce) {
241 |   * {
242 |     animation-duration: 0.01ms !important;
243 |     animation-iteration-count: 1 !important;
244 |     transition-duration: 0.01ms !important;
245 |   }
246 | }
247 | 
```

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

```typescript
  1 | /**
  2 |  * @fileoverview RunEvent 持久化
  3 |  * @description 实现事件的原子 seq 分配和存储
  4 |  */
  5 | 
  6 | import type { RunId } from '../domain/ids';
  7 | import type { RunEvent, RunEventInput, RunRecordV3 } from '../domain/events';
  8 | import { RR_ERROR_CODES, createRRError } from '../domain/errors';
  9 | import type { EventsStore } from '../engine/storage/storage-port';
 10 | import { RR_V3_STORES, withTransaction } from './db';
 11 | 
 12 | /**
 13 |  * IDB request helper - promisify IDBRequest with RRError wrapping
 14 |  */
 15 | function idbRequest<T>(request: IDBRequest<T>, context: string): Promise<T> {
 16 |   return new Promise((resolve, reject) => {
 17 |     request.onsuccess = () => resolve(request.result);
 18 |     request.onerror = () => {
 19 |       const error = request.error;
 20 |       reject(
 21 |         createRRError(
 22 |           RR_ERROR_CODES.INTERNAL,
 23 |           `IDB error in ${context}: ${error?.message ?? 'unknown'}`,
 24 |         ),
 25 |       );
 26 |     };
 27 |   });
 28 | }
 29 | 
 30 | /**
 31 |  * 创建 EventsStore 实现
 32 |  * @description
 33 |  * - append() 在单个事务中原子分配 seq
 34 |  * - seq 由 RunRecordV3.nextSeq 作为单一事实来源
 35 |  */
 36 | export function createEventsStore(): EventsStore {
 37 |   return {
 38 |     /**
 39 |      * 追加事件并原子分配 seq
 40 |      * @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq
 41 |      */
 42 |     async append(input: RunEventInput): Promise<RunEvent> {
 43 |       return withTransaction(
 44 |         [RR_V3_STORES.RUNS, RR_V3_STORES.EVENTS],
 45 |         'readwrite',
 46 |         async (stores) => {
 47 |           const runsStore = stores[RR_V3_STORES.RUNS];
 48 |           const eventsStore = stores[RR_V3_STORES.EVENTS];
 49 | 
 50 |           // Step 1: Read nextSeq from RunRecordV3 (single source of truth)
 51 |           const run = await idbRequest<RunRecordV3 | undefined>(
 52 |             runsStore.get(input.runId),
 53 |             `append.getRun(${input.runId})`,
 54 |           );
 55 | 
 56 |           if (!run) {
 57 |             throw createRRError(
 58 |               RR_ERROR_CODES.INTERNAL,
 59 |               `Run "${input.runId}" not found when appending event`,
 60 |             );
 61 |           }
 62 | 
 63 |           const seq = run.nextSeq;
 64 | 
 65 |           // Validate seq integrity
 66 |           if (!Number.isSafeInteger(seq) || seq < 0) {
 67 |             throw createRRError(
 68 |               RR_ERROR_CODES.INVARIANT_VIOLATION,
 69 |               `Invalid nextSeq for run "${input.runId}": ${String(seq)}`,
 70 |             );
 71 |           }
 72 | 
 73 |           // Step 2: Create complete event with allocated seq
 74 |           const event: RunEvent = {
 75 |             ...input,
 76 |             seq,
 77 |             ts: input.ts ?? Date.now(),
 78 |           } as RunEvent;
 79 | 
 80 |           // Step 3: Write event to events store
 81 |           await idbRequest(eventsStore.add(event), `append.addEvent(${input.runId}, seq=${seq})`);
 82 | 
 83 |           // Step 4: Increment nextSeq in runs store (same transaction)
 84 |           const updatedRun: RunRecordV3 = {
 85 |             ...run,
 86 |             nextSeq: seq + 1,
 87 |             updatedAt: Date.now(),
 88 |           };
 89 | 
 90 |           await idbRequest(
 91 |             runsStore.put(updatedRun),
 92 |             `append.updateNextSeq(${input.runId}, nextSeq=${seq + 1})`,
 93 |           );
 94 | 
 95 |           return event;
 96 |         },
 97 |       );
 98 |     },
 99 | 
100 |     /**
101 |      * 列出事件
102 |      * @description 利用复合主键 [runId, seq] 实现高效范围查询
103 |      */
104 |     async list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]> {
105 |       return withTransaction(RR_V3_STORES.EVENTS, 'readonly', async (stores) => {
106 |         const store = stores[RR_V3_STORES.EVENTS];
107 |         const fromSeq = opts?.fromSeq ?? 0;
108 |         const limit = opts?.limit;
109 | 
110 |         // Early return for zero limit
111 |         if (limit === 0) {
112 |           return [];
113 |         }
114 | 
115 |         return new Promise<RunEvent[]>((resolve, reject) => {
116 |           const results: RunEvent[] = [];
117 | 
118 |           // Use compound primary key [runId, seq] for efficient range query
119 |           // This yields events in seq-ascending order naturally
120 |           const range = IDBKeyRange.bound([runId, fromSeq], [runId, Number.MAX_SAFE_INTEGER]);
121 | 
122 |           const request = store.openCursor(range);
123 | 
124 |           request.onsuccess = () => {
125 |             const cursor = request.result;
126 | 
127 |             if (!cursor) {
128 |               resolve(results);
129 |               return;
130 |             }
131 | 
132 |             const event = cursor.value as RunEvent;
133 |             results.push(event);
134 | 
135 |             // Check limit
136 |             if (limit !== undefined && results.length >= limit) {
137 |               resolve(results);
138 |               return;
139 |             }
140 | 
141 |             cursor.continue();
142 |           };
143 | 
144 |           request.onerror = () => reject(request.error);
145 |         });
146 |       });
147 |     },
148 |   };
149 | }
150 | 
```

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

```typescript
  1 | /**
  2 |  * Slider Input Component
  3 |  *
  4 |  * A reusable "slider + input" control for numeric values:
  5 |  * - Left: native range slider for visual manipulation
  6 |  * - Right: InputContainer-backed numeric input for precise values
  7 |  *
  8 |  * Features:
  9 |  * - Bidirectional synchronization between slider and input
 10 |  * - Supports disabled state
 11 |  * - Accessible with ARIA labels
 12 |  *
 13 |  * Styling is defined in shadow-host.ts:
 14 |  * - `.we-slider-input`
 15 |  * - `.we-slider-input__slider`
 16 |  * - `.we-slider-input__number`
 17 |  */
 18 | 
 19 | import { createInputContainer, type InputContainer } from './input-container';
 20 | 
 21 | // =============================================================================
 22 | // Types
 23 | // =============================================================================
 24 | 
 25 | export interface SliderInputOptions {
 26 |   /** Accessible label for the range slider */
 27 |   sliderAriaLabel: string;
 28 |   /** Accessible label for the numeric input */
 29 |   inputAriaLabel: string;
 30 |   /** Minimum value for the slider */
 31 |   min: number;
 32 |   /** Maximum value for the slider */
 33 |   max: number;
 34 |   /** Step increment for the slider */
 35 |   step: number;
 36 |   /** Input mode for the numeric input (default: "decimal") */
 37 |   inputMode?: string;
 38 |   /** Fixed width for the numeric input in pixels (default: 72) */
 39 |   inputWidthPx?: number;
 40 | }
 41 | 
 42 | export interface SliderInput {
 43 |   /** Root container element */
 44 |   root: HTMLDivElement;
 45 |   /** Range slider element */
 46 |   slider: HTMLInputElement;
 47 |   /** Numeric input element */
 48 |   input: HTMLInputElement;
 49 |   /** Input container instance for advanced customization */
 50 |   inputContainer: InputContainer;
 51 |   /** Set disabled state for both controls */
 52 |   setDisabled(disabled: boolean): void;
 53 |   /** Set disabled state for slider only */
 54 |   setSliderDisabled(disabled: boolean): void;
 55 |   /** Set value for both controls */
 56 |   setValue(value: number): void;
 57 |   /** Set slider value only (without affecting input) */
 58 |   setSliderValue(value: number): void;
 59 | }
 60 | 
 61 | // =============================================================================
 62 | // Factory
 63 | // =============================================================================
 64 | 
 65 | /**
 66 |  * Create a slider input component with synchronized slider and input
 67 |  */
 68 | export function createSliderInput(options: SliderInputOptions): SliderInput {
 69 |   const {
 70 |     sliderAriaLabel,
 71 |     inputAriaLabel,
 72 |     min,
 73 |     max,
 74 |     step,
 75 |     inputMode = 'decimal',
 76 |     inputWidthPx = 72,
 77 |   } = options;
 78 | 
 79 |   // Root container
 80 |   const root = document.createElement('div');
 81 |   root.className = 'we-slider-input';
 82 | 
 83 |   // Range slider
 84 |   const slider = document.createElement('input');
 85 |   slider.type = 'range';
 86 |   slider.className = 'we-slider-input__slider';
 87 |   slider.min = String(min);
 88 |   slider.max = String(max);
 89 |   slider.step = String(step);
 90 |   slider.value = String(min);
 91 |   slider.setAttribute('aria-label', sliderAriaLabel);
 92 | 
 93 |   /**
 94 |    * Update the slider's progress color based on current value.
 95 |    * Uses CSS custom property --progress for the gradient.
 96 |    */
 97 |   function updateSliderProgress(): void {
 98 |     const value = parseFloat(slider.value);
 99 |     const minVal = parseFloat(slider.min);
100 |     const maxVal = parseFloat(slider.max);
101 |     const percent = ((value - minVal) / (maxVal - minVal)) * 100;
102 |     slider.style.setProperty('--progress', `${percent}%`);
103 |   }
104 | 
105 |   // Initialize progress
106 |   updateSliderProgress();
107 | 
108 |   // Update progress on input
109 |   slider.addEventListener('input', updateSliderProgress);
110 | 
111 |   // Numeric input using InputContainer
112 |   const inputContainer = createInputContainer({
113 |     ariaLabel: inputAriaLabel,
114 |     inputMode,
115 |     prefix: null,
116 |     suffix: null,
117 |     rootClassName: 'we-slider-input__number',
118 |   });
119 |   inputContainer.root.style.width = `${inputWidthPx}px`;
120 |   inputContainer.root.style.flex = '0 0 auto';
121 | 
122 |   root.append(slider, inputContainer.root);
123 | 
124 |   // Public methods
125 |   function setDisabled(disabled: boolean): void {
126 |     slider.disabled = disabled;
127 |     inputContainer.input.disabled = disabled;
128 |   }
129 | 
130 |   function setSliderDisabled(disabled: boolean): void {
131 |     slider.disabled = disabled;
132 |   }
133 | 
134 |   function setValue(value: number): void {
135 |     const stringValue = String(value);
136 |     slider.value = stringValue;
137 |     inputContainer.input.value = stringValue;
138 |     updateSliderProgress();
139 |   }
140 | 
141 |   function setSliderValue(value: number): void {
142 |     slider.value = String(value);
143 |     updateSliderProgress();
144 |   }
145 | 
146 |   return {
147 |     root,
148 |     slider,
149 |     input: inputContainer.input,
150 |     inputContainer,
151 |     setDisabled,
152 |     setSliderDisabled,
153 |     setValue,
154 |     setSliderValue,
155 |   };
156 | }
157 | 
```
Page 4/60FirstPrevNextLast