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 |
```