#
tokens: 49261/50000 22/574 files (page 5/60)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 5 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/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Shared SVG Icons for Web Editor UI
  3 |  *
  4 |  * All icons are created as inline SVG elements to:
  5 |  * - Avoid external asset dependencies
  6 |  * - Support theming via `currentColor`
  7 |  * - Enable direct DOM manipulation
  8 |  *
  9 |  * Design standards:
 10 |  * - ViewBox: 20x20 (default) or 24x24 (for specific icons)
 11 |  * - Stroke width: 2px
 12 |  * - Line caps/joins: round
 13 |  */
 14 | 
 15 | // =============================================================================
 16 | // Icon Factory Helpers
 17 | // =============================================================================
 18 | 
 19 | function createSvgElement(): SVGElement {
 20 |   const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
 21 |   svg.setAttribute('viewBox', '0 0 20 20');
 22 |   svg.setAttribute('fill', 'none');
 23 |   svg.setAttribute('aria-hidden', 'true');
 24 |   return svg;
 25 | }
 26 | 
 27 | function createSvgElement24(): SVGElement {
 28 |   const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
 29 |   svg.setAttribute('viewBox', '0 0 24 24');
 30 |   svg.setAttribute('fill', 'none');
 31 |   svg.setAttribute('aria-hidden', 'true');
 32 |   return svg;
 33 | }
 34 | 
 35 | function createStrokePath(d: string): SVGPathElement {
 36 |   const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 37 |   path.setAttribute('d', d);
 38 |   path.setAttribute('stroke', 'currentColor');
 39 |   path.setAttribute('stroke-width', '2');
 40 |   path.setAttribute('stroke-linecap', 'round');
 41 |   path.setAttribute('stroke-linejoin', 'round');
 42 |   return path;
 43 | }
 44 | 
 45 | // =============================================================================
 46 | // Icon Creators
 47 | // =============================================================================
 48 | 
 49 | /**
 50 |  * Minus icon (—) for minimize button
 51 |  */
 52 | export function createMinusIcon(): SVGElement {
 53 |   const svg = createSvgElement();
 54 |   svg.append(createStrokePath('M5 10h10'));
 55 |   return svg;
 56 | }
 57 | 
 58 | /**
 59 |  * Plus icon (+) for restore/expand button
 60 |  */
 61 | export function createPlusIcon(): SVGElement {
 62 |   const svg = createSvgElement();
 63 |   svg.append(createStrokePath('M10 5v10M5 10h10'));
 64 |   return svg;
 65 | }
 66 | 
 67 | /**
 68 |  * Close icon (×) for close button
 69 |  */
 70 | export function createCloseIcon(): SVGElement {
 71 |   const svg = createSvgElement();
 72 |   svg.append(createStrokePath('M6 6l8 8M14 6l-8 8'));
 73 |   return svg;
 74 | }
 75 | 
 76 | /**
 77 |  * Grip icon (6 dots) for drag handle
 78 |  */
 79 | export function createGripIcon(): SVGElement {
 80 |   const svg = createSvgElement();
 81 | 
 82 |   const DOT_POSITIONS: ReadonlyArray<readonly [number, number]> = [
 83 |     [7, 6],
 84 |     [13, 6],
 85 |     [7, 10],
 86 |     [13, 10],
 87 |     [7, 14],
 88 |     [13, 14],
 89 |   ];
 90 | 
 91 |   for (const [cx, cy] of DOT_POSITIONS) {
 92 |     const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
 93 |     circle.setAttribute('cx', String(cx));
 94 |     circle.setAttribute('cy', String(cy));
 95 |     circle.setAttribute('r', '1.4');
 96 |     circle.setAttribute('fill', 'currentColor');
 97 |     svg.append(circle);
 98 |   }
 99 | 
100 |   return svg;
101 | }
102 | 
103 | /**
104 |  * Chevron icon (▼) for collapse/expand indicator
105 |  */
106 | export function createChevronIcon(): SVGElement {
107 |   const svg = createSvgElement();
108 |   svg.classList.add('we-chevron');
109 |   svg.append(createStrokePath('M7 8l3 3 3-3'));
110 |   return svg;
111 | }
112 | 
113 | /**
114 |  * Undo icon (↶) for undo button
115 |  * Uses 24x24 viewBox matching toolbar-ui.html design spec
116 |  */
117 | export function createUndoIcon(): SVGElement {
118 |   const svg = createSvgElement24();
119 |   svg.append(createStrokePath('M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6'));
120 |   return svg;
121 | }
122 | 
123 | /**
124 |  * Redo icon (↷) for redo button
125 |  * Uses 24x24 viewBox matching toolbar-ui.html design spec
126 |  */
127 | export function createRedoIcon(): SVGElement {
128 |   const svg = createSvgElement24();
129 |   svg.append(createStrokePath('M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6'));
130 |   return svg;
131 | }
132 | 
133 | /**
134 |  * Chevron Up icon (^) for minimize/restore button
135 |  * Rotates 180deg when minimized to point down
136 |  */
137 | export function createChevronUpIcon(): SVGElement {
138 |   const svg = createSvgElement();
139 |   svg.append(createStrokePath('M6 12l4-4 4 4'));
140 |   return svg;
141 | }
142 | 
143 | /**
144 |  * Chevron Down icon (small, 24x24 viewBox) for dropdown buttons
145 |  * Matches toolbar-ui.html design spec
146 |  */
147 | export function createChevronDownSmallIcon(): SVGElement {
148 |   const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
149 |   svg.setAttribute('viewBox', '0 0 24 24');
150 |   svg.setAttribute('fill', 'none');
151 |   svg.setAttribute('aria-hidden', 'true');
152 | 
153 |   const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
154 |   path.setAttribute('d', 'M19 9l-7 7-7-7');
155 |   path.setAttribute('stroke', 'currentColor');
156 |   path.setAttribute('stroke-width', '2');
157 |   path.setAttribute('stroke-linecap', 'round');
158 |   path.setAttribute('stroke-linejoin', 'round');
159 |   svg.append(path);
160 | 
161 |   return svg;
162 | }
163 | 
```

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

```vue
  1 | <template>
  2 |   <div class="relative group/step">
  3 |     <!-- Timeline Node: Loading icon for running status, colored dot otherwise -->
  4 |     <template v-if="showLoadingIcon">
  5 |       <!-- Loading scribble icon for running/starting status -->
  6 |       <svg
  7 |         class="absolute loading-scribble flex-shrink-0"
  8 |         :style="{
  9 |           left: '-24px',
 10 |           top: nodeTopOffset,
 11 |           width: '14px',
 12 |           height: '14px',
 13 |         }"
 14 |         viewBox="0 0 100 100"
 15 |         fill="none"
 16 |       >
 17 |         <path
 18 |           d="M50 50 C50 48, 52 46, 54 46 C58 46, 60 50, 60 54 C60 60, 54 64, 48 64 C40 64, 36 56, 36 48 C36 38, 44 32, 54 32 C66 32, 74 42, 74 54 C74 68, 62 78, 48 78 C32 78, 22 64, 22 48 C22 30, 36 18, 54 18 C74 18, 88 34, 88 54 C88 76, 72 92, 50 92"
 19 |           stroke="var(--ac-accent, #D97757)"
 20 |           stroke-width="8"
 21 |           stroke-linecap="round"
 22 |         />
 23 |       </svg>
 24 |     </template>
 25 |     <template v-else>
 26 |       <!-- Colored dot -->
 27 |       <span
 28 |         class="absolute w-2 h-2 rounded-full transition-colors"
 29 |         :style="{
 30 |           left: '-20px',
 31 |           top: nodeTopOffset,
 32 |           backgroundColor: nodeColor,
 33 |           boxShadow: isStreaming ? 'var(--ac-timeline-node-pulse-shadow)' : 'none',
 34 |         }"
 35 |         :class="{ 'ac-pulse': isStreaming }"
 36 |       />
 37 |     </template>
 38 | 
 39 |     <!-- Content based on item kind -->
 40 |     <TimelineUserPromptStep v-if="item.kind === 'user_prompt'" :item="item" />
 41 |     <TimelineNarrativeStep v-else-if="item.kind === 'assistant_text'" :item="item" />
 42 |     <TimelineToolCallStep v-else-if="item.kind === 'tool_use'" :item="item" />
 43 |     <TimelineToolResultCardStep v-else-if="item.kind === 'tool_result'" :item="item" />
 44 |     <TimelineStatusStep
 45 |       v-else-if="item.kind === 'status'"
 46 |       :item="item"
 47 |       :hide-icon="showLoadingIcon"
 48 |     />
 49 |   </div>
 50 | </template>
 51 | 
 52 | <script lang="ts" setup>
 53 | import { computed } from 'vue';
 54 | import type { TimelineItem } from '../../composables/useAgentThreads';
 55 | import TimelineUserPromptStep from './timeline/TimelineUserPromptStep.vue';
 56 | import TimelineNarrativeStep from './timeline/TimelineNarrativeStep.vue';
 57 | import TimelineToolCallStep from './timeline/TimelineToolCallStep.vue';
 58 | import TimelineToolResultCardStep from './timeline/TimelineToolResultCardStep.vue';
 59 | import TimelineStatusStep from './timeline/TimelineStatusStep.vue';
 60 | 
 61 | const props = defineProps<{
 62 |   item: TimelineItem;
 63 |   /** Whether this is the last item in the timeline */
 64 |   isLast?: boolean;
 65 | }>();
 66 | 
 67 | const isStreaming = computed(() => {
 68 |   if (props.item.kind === 'assistant_text' || props.item.kind === 'tool_use') {
 69 |     return props.item.isStreaming;
 70 |   }
 71 |   if (props.item.kind === 'status') {
 72 |     return props.item.status === 'running' || props.item.status === 'starting';
 73 |   }
 74 |   return false;
 75 | });
 76 | 
 77 | // Show loading icon for status items that are running/starting
 78 | const showLoadingIcon = computed(() => {
 79 |   if (props.item.kind === 'status') {
 80 |     return props.item.status === 'running' || props.item.status === 'starting';
 81 |   }
 82 |   return false;
 83 | });
 84 | 
 85 | // Calculate top offset based on item type to align with first line of text
 86 | const nodeTopOffset = computed(() => {
 87 |   // user_prompt and assistant_text have py-1 (4px) + text-sm leading-relaxed
 88 |   if (props.item.kind === 'user_prompt' || props.item.kind === 'assistant_text') {
 89 |     return '12px';
 90 |   }
 91 |   // tool_use/tool_result have items-baseline with text-[11px]
 92 |   if (props.item.kind === 'tool_use' || props.item.kind === 'tool_result') {
 93 |     return '6px';
 94 |   }
 95 |   // status has flex items-center with text-xs (12px line-height ~18px)
 96 |   // For loading icon (14px), center it: (18-14)/2 = 2px
 97 |   if (props.item.kind === 'status') {
 98 |     return '2px';
 99 |   }
100 |   return '7px';
101 | });
102 | 
103 | const nodeColor = computed(() => {
104 |   // Active/streaming node
105 |   if (isStreaming.value) {
106 |     return 'var(--ac-timeline-node-active)';
107 |   }
108 | 
109 |   // Tool result nodes - success/error colors
110 |   if (props.item.kind === 'tool_result') {
111 |     if (props.item.isError) {
112 |       return 'var(--ac-danger)';
113 |     }
114 |     return 'var(--ac-success)';
115 |   }
116 | 
117 |   // Tool use nodes - use tool color
118 |   if (props.item.kind === 'tool_use') {
119 |     return 'var(--ac-timeline-node-tool)';
120 |   }
121 | 
122 |   // Assistant text - use accent color
123 |   if (props.item.kind === 'assistant_text') {
124 |     return 'var(--ac-timeline-node-active)';
125 |   }
126 | 
127 |   // User prompt - slightly stronger than default node for visual distinction
128 |   if (props.item.kind === 'user_prompt') {
129 |     return 'var(--ac-timeline-node-hover)';
130 |   }
131 | 
132 |   // Status nodes (completed/error/cancelled) - use muted color
133 |   if (props.item.kind === 'status') {
134 |     return 'var(--ac-timeline-node)';
135 |   }
136 | 
137 |   // Default node color
138 |   return 'var(--ac-timeline-node)';
139 | });
140 | </script>
141 | 
```

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

```typescript
  1 | import { TOOL_NAMES } from 'chrome-mcp-shared';
  2 | import { handleCallTool } from '@/entrypoints/background/tools';
  3 | import type { StepFill } from '../types';
  4 | import { locateElement } from '../selector-engine';
  5 | import { expandTemplatesDeep } from '../rr-utils';
  6 | import type { ExecCtx, ExecResult, NodeRuntime } from './types';
  7 | 
  8 | export const fillNode: NodeRuntime<StepFill> = {
  9 |   validate: (step) => {
 10 |     const ok = !!(step as any).target?.candidates?.length && 'value' in (step as any);
 11 |     return ok ? { ok } : { ok, errors: ['缺少目标选择器候选或输入值'] };
 12 |   },
 13 |   run: async (ctx: ExecCtx, step: StepFill) => {
 14 |     const s: any = step;
 15 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
 16 |     const firstTab = tabs && tabs[0];
 17 |     const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
 18 |     if (!tabId) throw new Error('Active tab not found');
 19 |     await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
 20 |     const located = await locateElement(tabId, s.target, ctx.frameId);
 21 |     const frameId = (located as any)?.frameId ?? ctx.frameId;
 22 |     const first = s.target?.candidates?.[0]?.type;
 23 |     const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
 24 |     const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
 25 |     const interpolate = (v: any) =>
 26 |       typeof v === 'string'
 27 |         ? v.replace(/\{([^}]+)\}/g, (_m, k) => (ctx.vars[k] ?? '').toString())
 28 |         : v;
 29 |     const value = interpolate(s.value);
 30 |     if ((located as any)?.ref) {
 31 |       const resolved: any = (await chrome.tabs.sendMessage(
 32 |         tabId,
 33 |         { action: 'resolveRef', ref: (located as any).ref } as any,
 34 |         { frameId } as any,
 35 |       )) as any;
 36 |       const rect = resolved?.rect;
 37 |       if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
 38 |     }
 39 |     const cssSelector = !(located as any)?.ref
 40 |       ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
 41 |       : undefined;
 42 |     if (cssSelector) {
 43 |       try {
 44 |         const attr: any = (await chrome.tabs.sendMessage(
 45 |           tabId,
 46 |           { action: 'getAttributeForSelector', selector: cssSelector, name: 'type' } as any,
 47 |           { frameId } as any,
 48 |         )) as any;
 49 |         const typeName = (attr && attr.value ? String(attr.value) : '').toLowerCase();
 50 |         if (typeName === 'file') {
 51 |           const uploadRes = await handleCallTool({
 52 |             name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
 53 |             args: { selector: cssSelector, filePath: String(value ?? '') },
 54 |           });
 55 |           if ((uploadRes as any).isError) throw new Error('file upload failed');
 56 |           if (fallbackUsed)
 57 |             ctx.logger({
 58 |               stepId: (step as any).id,
 59 |               status: 'success',
 60 |               message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
 61 |               fallbackUsed: true,
 62 |               fallbackFrom: String(first),
 63 |               fallbackTo: String(resolvedBy),
 64 |             } as any);
 65 |           return {} as ExecResult;
 66 |         }
 67 |       } catch {}
 68 |     }
 69 |     try {
 70 |       if (cssSelector)
 71 |         await handleCallTool({
 72 |           name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
 73 |           args: {
 74 |             type: 'MAIN',
 75 |             jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});} }catch(e){}`,
 76 |           },
 77 |         });
 78 |     } catch {}
 79 |     try {
 80 |       if ((located as any)?.ref)
 81 |         await chrome.tabs.sendMessage(
 82 |           tabId,
 83 |           { action: 'focusByRef', ref: (located as any).ref } as any,
 84 |           { frameId } as any,
 85 |         );
 86 |       else if (cssSelector)
 87 |         await handleCallTool({
 88 |           name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
 89 |           args: {
 90 |             type: 'MAIN',
 91 |             jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,
 92 |           },
 93 |         });
 94 |     } catch {}
 95 |     const res = await handleCallTool({
 96 |       name: TOOL_NAMES.BROWSER.FILL,
 97 |       args: {
 98 |         ref: (located as any)?.ref || (s as any).target?.ref,
 99 |         selector: cssSelector,
100 |         value,
101 |         frameId,
102 |       },
103 |     });
104 |     if ((res as any).isError) throw new Error('fill failed');
105 |     if (fallbackUsed)
106 |       ctx.logger({
107 |         stepId: (step as any).id,
108 |         status: 'success',
109 |         message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
110 |         fallbackUsed: true,
111 |         fallbackFrom: String(first),
112 |         fallbackTo: String(resolvedBy),
113 |       } as any);
114 |     return {} as ExecResult;
115 |   },
116 | };
117 | 
```

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

```typescript
  1 | import { TOOL_NAMES } from 'chrome-mcp-shared';
  2 | import { handleCallTool } from '@/entrypoints/background/tools';
  3 | import type { Step } from '../types';
  4 | import { locateElement } from '../selector-engine';
  5 | import { expandTemplatesDeep } from '../rr-utils';
  6 | import type { ExecCtx, ExecResult, NodeRuntime } from './types';
  7 | 
  8 | export const clickNode: NodeRuntime<any> = {
  9 |   validate: (step) => {
 10 |     const ok = !!(step as any).target?.candidates?.length;
 11 |     return ok ? { ok } : { ok, errors: ['缺少目标选择器候选'] };
 12 |   },
 13 |   run: async (ctx: ExecCtx, step: Step) => {
 14 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
 15 |     const firstTab = tabs && tabs[0];
 16 |     const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
 17 |     if (!tabId) throw new Error('Active tab not found');
 18 |     await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
 19 |     const s: any = expandTemplatesDeep(step as any, ctx.vars);
 20 |     const located = await locateElement(tabId, s.target, ctx.frameId);
 21 |     const frameId = (located as any)?.frameId ?? ctx.frameId;
 22 |     const first = s.target?.candidates?.[0]?.type;
 23 |     const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
 24 |     const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
 25 |     if ((located as any)?.ref) {
 26 |       const resolved: any = (await chrome.tabs.sendMessage(
 27 |         tabId,
 28 |         { action: 'resolveRef', ref: (located as any).ref } as any,
 29 |         { frameId } as any,
 30 |       )) as any;
 31 |       const rect = resolved?.rect;
 32 |       if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
 33 |     }
 34 |     const res = await handleCallTool({
 35 |       name: TOOL_NAMES.BROWSER.CLICK,
 36 |       args: {
 37 |         ref: (located as any)?.ref || (step as any).target?.ref,
 38 |         selector: !(located as any)?.ref
 39 |           ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
 40 |           : undefined,
 41 |         waitForNavigation: false,
 42 |         timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),
 43 |         frameId,
 44 |       },
 45 |     });
 46 |     if ((res as any).isError) throw new Error('click failed');
 47 |     if (fallbackUsed)
 48 |       ctx.logger({
 49 |         stepId: step.id,
 50 |         status: 'success',
 51 |         message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
 52 |         fallbackUsed: true,
 53 |         fallbackFrom: String(first),
 54 |         fallbackTo: String(resolvedBy),
 55 |       } as any);
 56 |     return {} as ExecResult;
 57 |   },
 58 | };
 59 | 
 60 | export const dblclickNode: NodeRuntime<any> = {
 61 |   validate: clickNode.validate,
 62 |   run: async (ctx: ExecCtx, step: Step) => {
 63 |     const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
 64 |     const firstTab = tabs && tabs[0];
 65 |     const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;
 66 |     if (!tabId) throw new Error('Active tab not found');
 67 |     await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
 68 |     const s: any = expandTemplatesDeep(step as any, ctx.vars);
 69 |     const located = await locateElement(tabId, s.target, ctx.frameId);
 70 |     const frameId = (located as any)?.frameId ?? ctx.frameId;
 71 |     const first = s.target?.candidates?.[0]?.type;
 72 |     const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');
 73 |     const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;
 74 |     if ((located as any)?.ref) {
 75 |       const resolved: any = (await chrome.tabs.sendMessage(
 76 |         tabId,
 77 |         { action: 'resolveRef', ref: (located as any).ref } as any,
 78 |         { frameId } as any,
 79 |       )) as any;
 80 |       const rect = resolved?.rect;
 81 |       if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');
 82 |     }
 83 |     const res = await handleCallTool({
 84 |       name: TOOL_NAMES.BROWSER.CLICK,
 85 |       args: {
 86 |         ref: (located as any)?.ref || (step as any).target?.ref,
 87 |         selector: !(located as any)?.ref
 88 |           ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value
 89 |           : undefined,
 90 |         waitForNavigation: false,
 91 |         timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),
 92 |         frameId,
 93 |         double: true,
 94 |       },
 95 |     });
 96 |     if ((res as any).isError) throw new Error('dblclick failed');
 97 |     if (fallbackUsed)
 98 |       ctx.logger({
 99 |         stepId: step.id,
100 |         status: 'success',
101 |         message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,
102 |         fallbackUsed: true,
103 |         fallbackFrom: String(first),
104 |         fallbackTo: String(resolvedBy),
105 |       } as any);
106 |     return {} as ExecResult;
107 |   },
108 | };
109 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Property Panel Types
  3 |  *
  4 |  * Type definitions for the property panel component.
  5 |  * The panel displays Design controls and DOM tree for the selected element.
  6 |  */
  7 | 
  8 | import type { TransactionManager } from '../../core/transaction-manager';
  9 | import type { PropsBridge } from '../../core/props-bridge';
 10 | import type { DesignTokensService } from '../../core/design-tokens';
 11 | import type { FloatingPosition } from '../floating-drag';
 12 | 
 13 | // =============================================================================
 14 | // Tab Types
 15 | // =============================================================================
 16 | 
 17 | /** Property panel tab identifiers */
 18 | export type PropertyPanelTab = 'design' | 'css' | 'props' | 'dom';
 19 | 
 20 | // =============================================================================
 21 | // Options Types
 22 | // =============================================================================
 23 | 
 24 | /** Options for creating the property panel */
 25 | export interface PropertyPanelOptions {
 26 |   /** Shadow UI container element (elements.uiRoot from shadow-host) */
 27 |   container: HTMLElement;
 28 | 
 29 |   /** Transaction manager for applying style changes with undo/redo support */
 30 |   transactionManager: TransactionManager;
 31 | 
 32 |   /** Bridge to the MAIN-world props agent (Phase 7) */
 33 |   propsBridge: PropsBridge;
 34 | 
 35 |   /**
 36 |    * Callback when user selects an element from the Components tree (DOM tab).
 37 |    * Used to update the editor's selection state.
 38 |    */
 39 |   onSelectElement: (element: Element) => void;
 40 | 
 41 |   /**
 42 |    * Optional callback to close the editor.
 43 |    * If provided, a close button will be shown in the header.
 44 |    */
 45 |   onRequestClose?: () => void;
 46 | 
 47 |   /**
 48 |    * Initial floating position (viewport coordinates).
 49 |    * When provided, the panel uses left/top positioning and becomes draggable.
 50 |    */
 51 |   initialPosition?: FloatingPosition | null;
 52 | 
 53 |   /**
 54 |    * Called whenever the floating position changes.
 55 |    * Use null to indicate the panel is in its default anchored position.
 56 |    */
 57 |   onPositionChange?: (position: FloatingPosition | null) => void;
 58 | 
 59 |   /** Initial tab to display (default: 'design') */
 60 |   defaultTab?: PropertyPanelTab;
 61 | 
 62 |   /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */
 63 |   tokensService?: DesignTokensService;
 64 | }
 65 | 
 66 | // =============================================================================
 67 | // Panel Interface
 68 | // =============================================================================
 69 | 
 70 | /** Property panel public interface */
 71 | export interface PropertyPanel {
 72 |   /**
 73 |    * Update the panel to display properties for the given element.
 74 |    * Pass null to show empty state.
 75 |    */
 76 |   setTarget(element: Element | null): void;
 77 | 
 78 |   /** Switch to a specific tab */
 79 |   setTab(tab: PropertyPanelTab): void;
 80 | 
 81 |   /** Get the currently active tab */
 82 |   getTab(): PropertyPanelTab;
 83 | 
 84 |   /** Force refresh the current controls (e.g., after external style change) */
 85 |   refresh(): void;
 86 | 
 87 |   /** Get current floating position (viewport coordinates), null when anchored */
 88 |   getPosition(): FloatingPosition | null;
 89 | 
 90 |   /** Set floating position (viewport coordinates), pass null to reset to anchored */
 91 |   setPosition(position: FloatingPosition | null): void;
 92 | 
 93 |   /** Cleanup and remove the panel */
 94 |   dispose(): void;
 95 | }
 96 | 
 97 | // =============================================================================
 98 | // Control Types
 99 | // =============================================================================
100 | 
101 | /** Common interface for design controls (Size, Spacing, Position, etc.) */
102 | export interface DesignControl {
103 |   /** Update the control to display values for the given element */
104 |   setTarget(element: Element | null): void;
105 | 
106 |   /** Refresh control values from current element styles */
107 |   refresh(): void;
108 | 
109 |   /** Cleanup the control */
110 |   dispose(): void;
111 | }
112 | 
113 | /** Factory function type for creating design controls */
114 | export type DesignControlFactory = (options: {
115 |   container: HTMLElement;
116 |   transactionManager: TransactionManager;
117 | }) => DesignControl;
118 | 
119 | // =============================================================================
120 | // Group Types
121 | // =============================================================================
122 | 
123 | /** State for a collapsible control group */
124 | export interface ControlGroupState {
125 |   /** Whether the group is collapsed */
126 |   collapsed: boolean;
127 | }
128 | 
129 | /** Collapsible control group interface */
130 | export interface ControlGroup {
131 |   /** The root element of the group */
132 |   root: HTMLElement;
133 | 
134 |   /** The body container where controls are mounted */
135 |   body: HTMLElement;
136 | 
137 |   /** Optional: Container for header action buttons (e.g., add button) */
138 |   headerActions?: HTMLElement;
139 | 
140 |   /** Set collapsed state */
141 |   setCollapsed(collapsed: boolean): void;
142 | 
143 |   /** Get current collapsed state */
144 |   isCollapsed(): boolean;
145 | 
146 |   /** Toggle collapsed state */
147 |   toggle(): void;
148 | }
149 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/css-defaults.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * CSS Defaults Provider
  3 |  *
  4 |  * Computes baseline (browser-default) computed style values for element tag names.
  5 |  * Used by the CSS panel to hide active declarations that match defaults.
  6 |  *
  7 |  * Isolation strategy:
  8 |  * - Mount a hidden host element with `all: initial` into the document.
  9 |  * - Attach an isolated ShadowRoot and insert probe elements (one per tag name).
 10 |  * - Page author styles do not cross the shadow boundary, so probe values reflect UA defaults.
 11 |  */
 12 | 
 13 | export interface CssDefaultsProvider {
 14 |   /** Precompute/cache baseline values for a tag + set of properties. */
 15 |   ensureBaselineValues(tagName: string, properties: readonly string[]): void;
 16 |   /** Get baseline computed value for a tag + property (cached). */
 17 |   getBaselineValue(tagName: string, property: string): string;
 18 |   /** Cleanup DOM and caches. */
 19 |   dispose(): void;
 20 | }
 21 | 
 22 | interface ProbeRoot {
 23 |   host: HTMLDivElement;
 24 |   shadow: ShadowRoot;
 25 |   container: HTMLDivElement;
 26 | }
 27 | 
 28 | function normalizeTagName(tagName: string): string {
 29 |   return String(tagName ?? '')
 30 |     .trim()
 31 |     .toLowerCase();
 32 | }
 33 | 
 34 | function normalizePropertyName(property: string): string {
 35 |   return String(property ?? '').trim();
 36 | }
 37 | 
 38 | export function createCssDefaultsProvider(): CssDefaultsProvider {
 39 |   let disposed = false;
 40 |   let probeRoot: ProbeRoot | null = null;
 41 | 
 42 |   const probeByTag = new Map<string, Element>();
 43 |   const cacheByTag = new Map<string, Map<string, string>>();
 44 | 
 45 |   function ensureProbeRoot(): ProbeRoot | null {
 46 |     if (disposed) return null;
 47 |     if (typeof document === 'undefined') return null;
 48 | 
 49 |     if (probeRoot?.host?.isConnected) return probeRoot;
 50 | 
 51 |     const mountPoint = document.documentElement ?? document.body;
 52 |     if (!mountPoint) return null;
 53 | 
 54 |     const host = document.createElement('div');
 55 |     host.setAttribute('aria-hidden', 'true');
 56 |     // Use fixed size to avoid layout-dependent property issues
 57 |     // all: initial resets inherited styles, fixed positioning takes out of flow
 58 |     host.style.cssText =
 59 |       'all: initial;' +
 60 |       'display: block;' +
 61 |       'position: fixed;' +
 62 |       'left: -100000px;' +
 63 |       'top: 0;' +
 64 |       'width: 100px;' +
 65 |       'height: 100px;' +
 66 |       'overflow: hidden;' +
 67 |       'pointer-events: none;' +
 68 |       'contain: layout style paint;' +
 69 |       'z-index: -1;' +
 70 |       'visibility: hidden;';
 71 | 
 72 |     const shadow = host.attachShadow({ mode: 'open' });
 73 | 
 74 |     const container = document.createElement('div');
 75 |     container.style.cssText = 'all: initial; display: block;';
 76 |     shadow.append(container);
 77 | 
 78 |     mountPoint.append(host);
 79 |     probeRoot = { host, shadow, container };
 80 |     return probeRoot;
 81 |   }
 82 | 
 83 |   function ensureProbeElement(tagName: string): Element | null {
 84 |     const tag = normalizeTagName(tagName);
 85 |     if (!tag) return null;
 86 | 
 87 |     const existing = probeByTag.get(tag);
 88 |     if (existing?.isConnected) return existing;
 89 | 
 90 |     const root = ensureProbeRoot();
 91 |     if (!root) return null;
 92 | 
 93 |     let probe: Element;
 94 |     try {
 95 |       probe = document.createElement(tag);
 96 |     } catch {
 97 |       probe = document.createElement('div');
 98 |     }
 99 | 
100 |     root.container.append(probe);
101 |     probeByTag.set(tag, probe);
102 |     return probe;
103 |   }
104 | 
105 |   function ensureBaselineValues(tagName: string, properties: readonly string[]): void {
106 |     const tag = normalizeTagName(tagName);
107 |     if (!tag) return;
108 | 
109 |     const list = (properties ?? []).map((p) => normalizePropertyName(p)).filter(Boolean);
110 |     if (list.length === 0) return;
111 | 
112 |     const perTag = cacheByTag.get(tag) ?? new Map<string, string>();
113 |     if (!cacheByTag.has(tag)) cacheByTag.set(tag, perTag);
114 | 
115 |     const missing: string[] = [];
116 |     for (const prop of list) {
117 |       if (!perTag.has(prop)) missing.push(prop);
118 |     }
119 |     if (missing.length === 0) return;
120 | 
121 |     const probe = ensureProbeElement(tag);
122 |     if (!probe) return;
123 | 
124 |     let computed: CSSStyleDeclaration | null = null;
125 |     try {
126 |       computed = window.getComputedStyle(probe);
127 |     } catch {
128 |       computed = null;
129 |     }
130 | 
131 |     if (!computed) {
132 |       for (const prop of missing) perTag.set(prop, '');
133 |       return;
134 |     }
135 | 
136 |     for (const prop of missing) {
137 |       let value = '';
138 |       try {
139 |         value = String(computed.getPropertyValue(prop) ?? '').trim();
140 |       } catch {
141 |         value = '';
142 |       }
143 |       perTag.set(prop, value);
144 |     }
145 |   }
146 | 
147 |   function getBaselineValue(tagName: string, property: string): string {
148 |     const tag = normalizeTagName(tagName);
149 |     const prop = normalizePropertyName(property);
150 |     if (!tag || !prop) return '';
151 | 
152 |     ensureBaselineValues(tag, [prop]);
153 |     return cacheByTag.get(tag)?.get(prop) ?? '';
154 |   }
155 | 
156 |   function dispose(): void {
157 |     disposed = true;
158 | 
159 |     try {
160 |       probeRoot?.host?.remove();
161 |     } catch {
162 |       // Best-effort
163 |     }
164 | 
165 |     probeRoot = null;
166 |     probeByTag.clear();
167 |     cacheByTag.clear();
168 |   }
169 | 
170 |   return {
171 |     ensureBaselineValues,
172 |     getBaselineValue,
173 |     dispose,
174 |   };
175 | }
176 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Adapter Policy Contract Tests
  3 |  *
  4 |  * Verifies that skipRetry and skipNavWait flags correctly modify
  5 |  * action execution behavior:
  6 |  * - skipRetry: removes action.policy.retry before execution
  7 |  * - skipNavWait: sets ctx.execution.skipNavWait for handlers
  8 |  */
  9 | 
 10 | import { describe, expect, it, vi, beforeEach } from 'vitest';
 11 | import { createStepExecutor } from '@/entrypoints/background/record-replay/actions/adapter';
 12 | import { createMockExecCtx, createMockStep } from './_test-helpers';
 13 | 
 14 | describe('adapter policy flags contract', () => {
 15 |   let registryExecute: ReturnType<typeof vi.fn>;
 16 |   let mockRegistry: any;
 17 | 
 18 |   beforeEach(() => {
 19 |     registryExecute = vi.fn(async () => ({ status: 'success' }));
 20 |     mockRegistry = {
 21 |       get: vi.fn(() => ({ type: 'fill' })), // Returns truthy = handler exists
 22 |       execute: registryExecute,
 23 |     };
 24 |   });
 25 | 
 26 |   describe('skipRetry flag', () => {
 27 |     it('removes action.policy.retry when skipRetry is true', async () => {
 28 |       const executor = createStepExecutor(mockRegistry);
 29 | 
 30 |       await executor(
 31 |         createMockExecCtx(),
 32 |         createMockStep('fill', {
 33 |           retry: { count: 3, intervalMs: 100, backoff: 'exp' },
 34 |           target: { candidates: [{ type: 'css', value: '#input' }] },
 35 |           value: 'test',
 36 |         }),
 37 |         1, // tabId
 38 |         { skipRetry: true },
 39 |       );
 40 | 
 41 |       expect(registryExecute).toHaveBeenCalledTimes(1);
 42 |       const [, action] = registryExecute.mock.calls[0];
 43 |       expect(action.policy?.retry).toBeUndefined();
 44 |     });
 45 | 
 46 |     it('preserves action.policy.retry when skipRetry is false', async () => {
 47 |       const executor = createStepExecutor(mockRegistry);
 48 | 
 49 |       await executor(
 50 |         createMockExecCtx(),
 51 |         createMockStep('fill', {
 52 |           retry: { count: 3, intervalMs: 100, backoff: 'exp' },
 53 |           target: { candidates: [{ type: 'css', value: '#input' }] },
 54 |           value: 'test',
 55 |         }),
 56 |         1,
 57 |         { skipRetry: false },
 58 |       );
 59 | 
 60 |       expect(registryExecute).toHaveBeenCalledTimes(1);
 61 |       const [, action] = registryExecute.mock.calls[0];
 62 |       expect(action.policy?.retry).toBeDefined();
 63 |       expect(action.policy.retry.retries).toBe(3);
 64 |     });
 65 | 
 66 |     it('preserves action.policy.retry when skipRetry is not specified', async () => {
 67 |       const executor = createStepExecutor(mockRegistry);
 68 | 
 69 |       await executor(
 70 |         createMockExecCtx(),
 71 |         createMockStep('fill', {
 72 |           retry: { count: 2, intervalMs: 50 },
 73 |           target: { candidates: [{ type: 'css', value: '#input' }] },
 74 |           value: 'test',
 75 |         }),
 76 |         1,
 77 |         {}, // No skipRetry
 78 |       );
 79 | 
 80 |       const [, action] = registryExecute.mock.calls[0];
 81 |       expect(action.policy?.retry).toBeDefined();
 82 |     });
 83 |   });
 84 | 
 85 |   describe('skipNavWait flag', () => {
 86 |     it('sets ctx.execution.skipNavWait when skipNavWait is true', async () => {
 87 |       const executor = createStepExecutor(mockRegistry);
 88 | 
 89 |       await executor(
 90 |         createMockExecCtx(),
 91 |         createMockStep('click', {
 92 |           target: { candidates: [{ type: 'css', value: '#btn' }] },
 93 |         }),
 94 |         1,
 95 |         { skipNavWait: true },
 96 |       );
 97 | 
 98 |       expect(registryExecute).toHaveBeenCalledTimes(1);
 99 |       const [actionCtx] = registryExecute.mock.calls[0];
100 |       expect(actionCtx.execution?.skipNavWait).toBe(true);
101 |     });
102 | 
103 |     it('does not set ctx.execution when skipNavWait is false', async () => {
104 |       const executor = createStepExecutor(mockRegistry);
105 | 
106 |       await executor(
107 |         createMockExecCtx(),
108 |         createMockStep('click', {
109 |           target: { candidates: [{ type: 'css', value: '#btn' }] },
110 |         }),
111 |         1,
112 |         { skipNavWait: false },
113 |       );
114 | 
115 |       const [actionCtx] = registryExecute.mock.calls[0];
116 |       expect(actionCtx.execution).toBeUndefined();
117 |     });
118 | 
119 |     it('does not set ctx.execution when skipNavWait is not specified', async () => {
120 |       const executor = createStepExecutor(mockRegistry);
121 | 
122 |       await executor(
123 |         createMockExecCtx(),
124 |         createMockStep('navigate', {
125 |           url: 'https://example.com',
126 |         }),
127 |         1,
128 |         {}, // No skipNavWait
129 |       );
130 | 
131 |       const [actionCtx] = registryExecute.mock.calls[0];
132 |       expect(actionCtx.execution).toBeUndefined();
133 |     });
134 |   });
135 | 
136 |   describe('combined flags', () => {
137 |     it('applies both skipRetry and skipNavWait together', async () => {
138 |       const executor = createStepExecutor(mockRegistry);
139 | 
140 |       await executor(
141 |         createMockExecCtx(),
142 |         createMockStep('click', {
143 |           retry: { count: 5, intervalMs: 200 },
144 |           target: { candidates: [{ type: 'css', value: '#btn' }] },
145 |         }),
146 |         1,
147 |         { skipRetry: true, skipNavWait: true },
148 |       );
149 | 
150 |       const [actionCtx, action] = registryExecute.mock.calls[0];
151 |       expect(action.policy?.retry).toBeUndefined();
152 |       expect(actionCtx.execution?.skipNavWait).toBe(true);
153 |     });
154 |   });
155 | });
156 | 
```

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

```typescript
  1 | /**
  2 |  * Web Editor V2 Constants
  3 |  *
  4 |  * Centralized configuration values for the visual editor.
  5 |  * All magic strings/numbers should be defined here.
  6 |  */
  7 | 
  8 | /** Editor version number */
  9 | export const WEB_EDITOR_V2_VERSION = 2 as const;
 10 | 
 11 | /** Log prefix for console messages */
 12 | export const WEB_EDITOR_V2_LOG_PREFIX = '[WebEditorV2]' as const;
 13 | 
 14 | // =============================================================================
 15 | // DOM Element IDs
 16 | // =============================================================================
 17 | 
 18 | /** Shadow host element ID */
 19 | export const WEB_EDITOR_V2_HOST_ID = '__mcp_web_editor_v2_host__';
 20 | 
 21 | /** Overlay container ID (for Canvas and visual feedback) */
 22 | export const WEB_EDITOR_V2_OVERLAY_ID = '__mcp_web_editor_v2_overlay__';
 23 | 
 24 | /** UI container ID (for panels and controls) */
 25 | export const WEB_EDITOR_V2_UI_ID = '__mcp_web_editor_v2_ui__';
 26 | 
 27 | // =============================================================================
 28 | // Styling
 29 | // =============================================================================
 30 | 
 31 | /** Maximum z-index to ensure editor is always on top */
 32 | export const WEB_EDITOR_V2_Z_INDEX = 2147483647;
 33 | 
 34 | /** Default panel width */
 35 | export const WEB_EDITOR_V2_PANEL_WIDTH = 320;
 36 | 
 37 | // =============================================================================
 38 | // Colors (Design System)
 39 | // =============================================================================
 40 | 
 41 | export const WEB_EDITOR_V2_COLORS = {
 42 |   /** Hover highlight color */
 43 |   hover: '#3b82f6', // blue-500
 44 |   /** Selected element color */
 45 |   selected: '#22c55e', // green-500
 46 |   /** Selection box border */
 47 |   selectionBorder: '#6366f1', // indigo-500
 48 |   /** Drag ghost color */
 49 |   dragGhost: 'rgba(99, 102, 241, 0.3)',
 50 |   /** Insertion line color */
 51 |   insertionLine: '#f59e0b', // amber-500
 52 |   /** Alignment guide line color (snap guides) */
 53 |   guideLine: '#ec4899', // pink-500
 54 |   /** Distance label background (Phase 4.3) */
 55 |   distanceLabelBg: 'rgba(15, 23, 42, 0.92)', // slate-900 @ 92%
 56 |   /** Distance label border (Phase 4.3) */
 57 |   distanceLabelBorder: 'rgba(51, 65, 85, 0.5)', // slate-600 @ 50%
 58 |   /** Distance label text (Phase 4.3) */
 59 |   distanceLabelText: 'rgba(255, 255, 255, 0.98)',
 60 | } as const;
 61 | 
 62 | // =============================================================================
 63 | // Drag Reorder (Phase 2.4-2.6)
 64 | // =============================================================================
 65 | 
 66 | /** Minimum pointer movement (px) to start dragging */
 67 | export const WEB_EDITOR_V2_DRAG_THRESHOLD_PX = 5;
 68 | 
 69 | /** Hysteresis (px) for stable before/after decision to avoid flip-flop */
 70 | export const WEB_EDITOR_V2_DRAG_HYSTERESIS_PX = 6;
 71 | 
 72 | /** Max elements to inspect per hit-test (elementsFromPoint) */
 73 | export const WEB_EDITOR_V2_DRAG_MAX_HIT_ELEMENTS = 8;
 74 | 
 75 | /** Insertion indicator line width in CSS pixels */
 76 | export const WEB_EDITOR_V2_INSERTION_LINE_WIDTH = 3;
 77 | 
 78 | // =============================================================================
 79 | // Snapping & Alignment Guides (Phase 4.2)
 80 | // =============================================================================
 81 | 
 82 | /** Snap threshold in CSS pixels - distance at which snapping activates */
 83 | export const WEB_EDITOR_V2_SNAP_THRESHOLD_PX = 6;
 84 | 
 85 | /** Hysteresis in CSS pixels - keeps snap stable near boundary to prevent flicker */
 86 | export const WEB_EDITOR_V2_SNAP_HYSTERESIS_PX = 2;
 87 | 
 88 | /** Maximum sibling elements to consider for snapping (nearest first) */
 89 | export const WEB_EDITOR_V2_SNAP_MAX_ANCHOR_ELEMENTS = 30;
 90 | 
 91 | /** Maximum siblings to scan before applying distance filter */
 92 | export const WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN = 300;
 93 | 
 94 | /** Alignment guide line width in CSS pixels */
 95 | export const WEB_EDITOR_V2_GUIDE_LINE_WIDTH = 1;
 96 | 
 97 | // =============================================================================
 98 | // Distance Labels (Phase 4.3)
 99 | // =============================================================================
100 | 
101 | /** Minimum distance (px) to display a label - hides 0 and sub-pixel gaps */
102 | export const WEB_EDITOR_V2_DISTANCE_LABEL_MIN_PX = 1;
103 | 
104 | /** Measurement line width in CSS pixels */
105 | export const WEB_EDITOR_V2_DISTANCE_LINE_WIDTH = 1;
106 | 
107 | /** Tick size at the ends of measurement lines (CSS pixels) */
108 | export const WEB_EDITOR_V2_DISTANCE_TICK_SIZE = 4;
109 | 
110 | /** Font used for distance label pills */
111 | export const WEB_EDITOR_V2_DISTANCE_LABEL_FONT =
112 |   '600 11px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
113 | 
114 | /** Horizontal padding inside distance label pill (CSS pixels) */
115 | export const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X = 6;
116 | 
117 | /** Vertical padding inside distance label pill (CSS pixels) */
118 | export const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y = 3;
119 | 
120 | /** Border radius for distance label pill (CSS pixels) */
121 | export const WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS = 4;
122 | 
123 | /** Offset from the measurement line to place the pill (CSS pixels) */
124 | export const WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET = 8;
125 | 
```

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

```typescript
  1 | /**
  2 |  * Vue composable for floating drag functionality.
  3 |  * Wraps the installFloatingDrag utility for use in Vue components.
  4 |  */
  5 | 
  6 | import { ref, onMounted, onUnmounted, type Ref } from 'vue';
  7 | import {
  8 |   installFloatingDrag,
  9 |   type FloatingPosition,
 10 | } from '@/entrypoints/web-editor-v2/ui/floating-drag';
 11 | 
 12 | const STORAGE_KEY = 'sidepanel_navigator_position';
 13 | 
 14 | export interface UseFloatingDragOptions {
 15 |   /** Storage key for position persistence */
 16 |   storageKey?: string;
 17 |   /** Margin from viewport edges in pixels */
 18 |   clampMargin?: number;
 19 |   /** Threshold for distinguishing click vs drag (ms) */
 20 |   clickThresholdMs?: number;
 21 |   /** Movement threshold for drag activation (px) */
 22 |   moveThresholdPx?: number;
 23 |   /** Default position calculator (called when no saved position exists) */
 24 |   getDefaultPosition?: () => FloatingPosition;
 25 | }
 26 | 
 27 | export interface UseFloatingDragReturn {
 28 |   /** Current position (reactive) */
 29 |   position: Ref<FloatingPosition>;
 30 |   /** Whether dragging is in progress */
 31 |   isDragging: Ref<boolean>;
 32 |   /** Reset position to default */
 33 |   resetToDefault: () => void;
 34 |   /** Computed style object for binding */
 35 |   positionStyle: Ref<{ left: string; top: string }>;
 36 | }
 37 | 
 38 | /**
 39 |  * Calculate default position (bottom-right corner with margin)
 40 |  */
 41 | function getDefaultBottomRightPosition(
 42 |   buttonSize: number = 40,
 43 |   margin: number = 12,
 44 | ): FloatingPosition {
 45 |   return {
 46 |     left: window.innerWidth - buttonSize - margin,
 47 |     top: window.innerHeight - buttonSize - margin,
 48 |   };
 49 | }
 50 | 
 51 | /**
 52 |  * Load position from chrome.storage.local
 53 |  */
 54 | async function loadPosition(storageKey: string): Promise<FloatingPosition | null> {
 55 |   try {
 56 |     const result = await chrome.storage.local.get(storageKey);
 57 |     const saved = result[storageKey];
 58 |     if (
 59 |       saved &&
 60 |       typeof saved.left === 'number' &&
 61 |       typeof saved.top === 'number' &&
 62 |       Number.isFinite(saved.left) &&
 63 |       Number.isFinite(saved.top)
 64 |     ) {
 65 |       return saved as FloatingPosition;
 66 |     }
 67 |   } catch (e) {
 68 |     console.warn('Failed to load navigator position:', e);
 69 |   }
 70 |   return null;
 71 | }
 72 | 
 73 | /**
 74 |  * Save position to chrome.storage.local
 75 |  */
 76 | async function savePosition(storageKey: string, position: FloatingPosition): Promise<void> {
 77 |   try {
 78 |     await chrome.storage.local.set({ [storageKey]: position });
 79 |   } catch (e) {
 80 |     console.warn('Failed to save navigator position:', e);
 81 |   }
 82 | }
 83 | 
 84 | /**
 85 |  * Vue composable for making an element draggable with position persistence.
 86 |  */
 87 | export function useFloatingDrag(
 88 |   handleRef: Ref<HTMLElement | null>,
 89 |   targetRef: Ref<HTMLElement | null>,
 90 |   options: UseFloatingDragOptions = {},
 91 | ): UseFloatingDragReturn {
 92 |   const {
 93 |     storageKey = STORAGE_KEY,
 94 |     clampMargin = 12,
 95 |     clickThresholdMs = 150,
 96 |     moveThresholdPx = 5,
 97 |     getDefaultPosition = () => getDefaultBottomRightPosition(40, clampMargin),
 98 |   } = options;
 99 | 
100 |   const position = ref<FloatingPosition>(getDefaultPosition());
101 |   const isDragging = ref(false);
102 |   const positionStyle = ref({ left: `${position.value.left}px`, top: `${position.value.top}px` });
103 | 
104 |   let cleanup: (() => void) | null = null;
105 | 
106 |   function updatePositionStyle(): void {
107 |     positionStyle.value = {
108 |       left: `${position.value.left}px`,
109 |       top: `${position.value.top}px`,
110 |     };
111 |   }
112 | 
113 |   function resetToDefault(): void {
114 |     position.value = getDefaultPosition();
115 |     updatePositionStyle();
116 |     savePosition(storageKey, position.value);
117 |   }
118 | 
119 |   async function initPosition(): Promise<void> {
120 |     const saved = await loadPosition(storageKey);
121 |     if (saved) {
122 |       // Validate position is within current viewport
123 |       const maxLeft = window.innerWidth - 40 - clampMargin;
124 |       const maxTop = window.innerHeight - 40 - clampMargin;
125 |       position.value = {
126 |         left: Math.min(Math.max(clampMargin, saved.left), maxLeft),
127 |         top: Math.min(Math.max(clampMargin, saved.top), maxTop),
128 |       };
129 |     } else {
130 |       position.value = getDefaultPosition();
131 |     }
132 |     updatePositionStyle();
133 |   }
134 | 
135 |   onMounted(async () => {
136 |     await initPosition();
137 | 
138 |     // Wait for refs to be available
139 |     await new Promise((resolve) => setTimeout(resolve, 0));
140 | 
141 |     if (!handleRef.value || !targetRef.value) {
142 |       console.warn('useFloatingDrag: handleRef or targetRef is null');
143 |       return;
144 |     }
145 | 
146 |     cleanup = installFloatingDrag({
147 |       handleEl: handleRef.value,
148 |       targetEl: targetRef.value,
149 |       onPositionChange: (pos) => {
150 |         position.value = pos;
151 |         updatePositionStyle();
152 |         savePosition(storageKey, pos);
153 |       },
154 |       clampMargin,
155 |       clickThresholdMs,
156 |       moveThresholdPx,
157 |     });
158 | 
159 |     // Monitor dragging state via data attribute
160 |     const observer = new MutationObserver(() => {
161 |       isDragging.value = handleRef.value?.dataset.dragging === 'true';
162 |     });
163 |     if (handleRef.value) {
164 |       observer.observe(handleRef.value, { attributes: true, attributeFilter: ['data-dragging'] });
165 |     }
166 |   });
167 | 
168 |   onUnmounted(() => {
169 |     cleanup?.();
170 |   });
171 | 
172 |   return {
173 |     position,
174 |     isDragging,
175 |     resetToDefault,
176 |     positionStyle,
177 |   };
178 | }
179 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/inject-scripts/screenshot-helper.js:
--------------------------------------------------------------------------------

```javascript
  1 | /* eslint-disable */
  2 | /**
  3 |  * Screenshot helper content script
  4 |  * Handles page preparation, scrolling, element positioning, etc.
  5 |  */
  6 | 
  7 | if (window.__SCREENSHOT_HELPER_INITIALIZED__) {
  8 |   // Already initialized, skip
  9 | } else {
 10 |   window.__SCREENSHOT_HELPER_INITIALIZED__ = true;
 11 | 
 12 |   // Save original styles
 13 |   let originalOverflowStyle = '';
 14 |   let hiddenFixedElements = [];
 15 | 
 16 |   /**
 17 |    * Get fixed/sticky positioned elements
 18 |    * @returns Array of fixed/sticky elements
 19 |    */
 20 |   function getFixedElements() {
 21 |     const fixed = [];
 22 | 
 23 |     document.querySelectorAll('*').forEach((el) => {
 24 |       const htmlEl = el;
 25 |       const style = window.getComputedStyle(htmlEl);
 26 |       if (style.position === 'fixed' || style.position === 'sticky') {
 27 |         // Filter out tiny or invisible elements, and elements that are part of the extension UI
 28 |         if (
 29 |           htmlEl.offsetWidth > 1 &&
 30 |           htmlEl.offsetHeight > 1 &&
 31 |           !htmlEl.id.startsWith('chrome-mcp-')
 32 |         ) {
 33 |           fixed.push({
 34 |             element: htmlEl,
 35 |             originalDisplay: htmlEl.style.display,
 36 |             originalVisibility: htmlEl.style.visibility,
 37 |           });
 38 |         }
 39 |       }
 40 |     });
 41 |     return fixed;
 42 |   }
 43 | 
 44 |   /**
 45 |    * Hide fixed/sticky elements
 46 |    */
 47 |   function hideFixedElements() {
 48 |     hiddenFixedElements = getFixedElements();
 49 |     hiddenFixedElements.forEach((item) => {
 50 |       item.element.style.display = 'none';
 51 |     });
 52 |   }
 53 | 
 54 |   /**
 55 |    * Restore fixed/sticky elements
 56 |    */
 57 |   function showFixedElements() {
 58 |     hiddenFixedElements.forEach((item) => {
 59 |       item.element.style.display = item.originalDisplay || '';
 60 |     });
 61 |     hiddenFixedElements = [];
 62 |   }
 63 | 
 64 |   // Listen for messages from the extension
 65 |   chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
 66 |     // Respond to ping message
 67 |     if (request.action === 'chrome_screenshot_ping') {
 68 |       sendResponse({ status: 'pong' });
 69 |       return false; // Synchronous response
 70 |     }
 71 | 
 72 |     // Prepare page for capture
 73 |     else if (request.action === 'preparePageForCapture') {
 74 |       originalOverflowStyle = document.documentElement.style.overflow;
 75 |       document.documentElement.style.overflow = 'hidden'; // Hide main scrollbar
 76 |       if (request.options?.fullPage) {
 77 |         // Only hide fixed elements for full page to avoid flicker
 78 |         hideFixedElements();
 79 |       }
 80 |       // Give styles a moment to apply
 81 |       setTimeout(() => {
 82 |         sendResponse({ success: true });
 83 |       }, 50);
 84 |       return true; // Async response
 85 |     }
 86 | 
 87 |     // Get page details
 88 |     else if (request.action === 'getPageDetails') {
 89 |       const body = document.body;
 90 |       const html = document.documentElement;
 91 |       sendResponse({
 92 |         totalWidth: Math.max(
 93 |           body.scrollWidth,
 94 |           body.offsetWidth,
 95 |           html.clientWidth,
 96 |           html.scrollWidth,
 97 |           html.offsetWidth,
 98 |         ),
 99 |         totalHeight: Math.max(
100 |           body.scrollHeight,
101 |           body.offsetHeight,
102 |           html.clientHeight,
103 |           html.scrollHeight,
104 |           html.offsetHeight,
105 |         ),
106 |         viewportWidth: window.innerWidth,
107 |         viewportHeight: window.innerHeight,
108 |         devicePixelRatio: window.devicePixelRatio || 1,
109 |         currentScrollX: window.scrollX,
110 |         currentScrollY: window.scrollY,
111 |       });
112 |     }
113 | 
114 |     // Get element details
115 |     else if (request.action === 'getElementDetails') {
116 |       const element = document.querySelector(request.selector);
117 |       if (element) {
118 |         element.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
119 |         setTimeout(() => {
120 |           // Wait for scroll
121 |           const rect = element.getBoundingClientRect();
122 |           sendResponse({
123 |             rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
124 |             devicePixelRatio: window.devicePixelRatio || 1,
125 |           });
126 |         }, 200); // Increased delay for scrollIntoView
127 |         return true; // Async response
128 |       } else {
129 |         sendResponse({ error: `Element with selector "${request.selector}" not found.` });
130 |       }
131 |       return true; // Async response
132 |     }
133 | 
134 |     // Scroll page
135 |     else if (request.action === 'scrollPage') {
136 |       window.scrollTo({ left: request.x, top: request.y, behavior: 'instant' });
137 |       // Wait for scroll and potential reflows/lazy-loading
138 |       setTimeout(() => {
139 |         sendResponse({
140 |           success: true,
141 |           newScrollX: window.scrollX,
142 |           newScrollY: window.scrollY,
143 |         });
144 |       }, request.scrollDelay || 300); // Configurable delay
145 |       return true; // Async response
146 |     }
147 | 
148 |     // Reset page
149 |     else if (request.action === 'resetPageAfterCapture') {
150 |       document.documentElement.style.overflow = originalOverflowStyle;
151 |       showFixedElements();
152 |       if (typeof request.scrollX !== 'undefined' && typeof request.scrollY !== 'undefined') {
153 |         window.scrollTo({ left: request.scrollX, top: request.scrollY, behavior: 'instant' });
154 |       }
155 |       sendResponse({ success: true });
156 |     }
157 | 
158 |     return false; // Synchronous response
159 |   });
160 | }
161 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * @fileoverview 工件(Artifacts)接口
  3 |  * @description 定义截图等工件的获取和存储接口
  4 |  */
  5 | 
  6 | import type { NodeId, RunId } from '../../domain/ids';
  7 | import type { RRError } from '../../domain/errors';
  8 | import { RR_ERROR_CODES, createRRError } from '../../domain/errors';
  9 | 
 10 | /**
 11 |  * 截图结果
 12 |  */
 13 | export type ScreenshotResult = { ok: true; base64: string } | { ok: false; error: RRError };
 14 | 
 15 | /**
 16 |  * 工件服务接口
 17 |  * @description 提供工件获取和存储功能
 18 |  */
 19 | export interface ArtifactService {
 20 |   /**
 21 |    * 截取页面截图
 22 |    * @param tabId Tab ID
 23 |    * @param options 截图选项
 24 |    */
 25 |   screenshot(
 26 |     tabId: number,
 27 |     options?: {
 28 |       format?: 'png' | 'jpeg';
 29 |       quality?: number;
 30 |     },
 31 |   ): Promise<ScreenshotResult>;
 32 | 
 33 |   /**
 34 |    * 保存截图
 35 |    * @param runId Run ID
 36 |    * @param nodeId Node ID
 37 |    * @param base64 截图数据
 38 |    * @param filename 文件名(可选)
 39 |    */
 40 |   saveScreenshot(
 41 |     runId: RunId,
 42 |     nodeId: NodeId,
 43 |     base64: string,
 44 |     filename?: string,
 45 |   ): Promise<{ savedAs: string } | { error: RRError }>;
 46 | }
 47 | 
 48 | /**
 49 |  * 创建 NotImplemented 的 ArtifactService
 50 |  * @description Phase 0-1 占位实现
 51 |  */
 52 | export function createNotImplementedArtifactService(): ArtifactService {
 53 |   return {
 54 |     screenshot: async () => ({
 55 |       ok: false,
 56 |       error: createRRError(RR_ERROR_CODES.INTERNAL, 'ArtifactService.screenshot not implemented'),
 57 |     }),
 58 |     saveScreenshot: async () => ({
 59 |       error: createRRError(
 60 |         RR_ERROR_CODES.INTERNAL,
 61 |         'ArtifactService.saveScreenshot not implemented',
 62 |       ),
 63 |     }),
 64 |   };
 65 | }
 66 | 
 67 | /**
 68 |  * 创建基于 chrome.tabs.captureVisibleTab 的 ArtifactService
 69 |  * @description 使用 Chrome API 截取可见标签页
 70 |  */
 71 | export function createChromeArtifactService(): ArtifactService {
 72 |   // In-memory storage for screenshots (could be replaced with IndexedDB)
 73 |   const screenshotStore = new Map<string, string>();
 74 | 
 75 |   return {
 76 |     screenshot: async (tabId, options) => {
 77 |       try {
 78 |         // Get the window ID for the tab
 79 |         const tab = await chrome.tabs.get(tabId);
 80 |         if (!tab.windowId) {
 81 |           return {
 82 |             ok: false,
 83 |             error: createRRError(RR_ERROR_CODES.INTERNAL, `Tab ${tabId} has no window`),
 84 |           };
 85 |         }
 86 | 
 87 |         // Capture the visible tab
 88 |         const format = options?.format ?? 'png';
 89 |         const quality = options?.quality ?? 100;
 90 | 
 91 |         const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
 92 |           format,
 93 |           quality: format === 'jpeg' ? quality : undefined,
 94 |         });
 95 | 
 96 |         // Extract base64 from data URL
 97 |         const base64Match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
 98 |         if (!base64Match) {
 99 |           return {
100 |             ok: false,
101 |             error: createRRError(RR_ERROR_CODES.INTERNAL, 'Invalid screenshot data URL'),
102 |           };
103 |         }
104 | 
105 |         return { ok: true, base64: base64Match[1] };
106 |       } catch (e) {
107 |         const message = e instanceof Error ? e.message : String(e);
108 |         return {
109 |           ok: false,
110 |           error: createRRError(RR_ERROR_CODES.INTERNAL, `Screenshot failed: ${message}`),
111 |         };
112 |       }
113 |     },
114 | 
115 |     saveScreenshot: async (runId, nodeId, base64, filename) => {
116 |       try {
117 |         // Generate filename if not provided
118 |         const savedAs = filename ?? `${runId}_${nodeId}_${Date.now()}.png`;
119 |         const key = `${runId}/${savedAs}`;
120 | 
121 |         // Store in memory (in production, this would go to IndexedDB or cloud storage)
122 |         screenshotStore.set(key, base64);
123 | 
124 |         return { savedAs };
125 |       } catch (e) {
126 |         const message = e instanceof Error ? e.message : String(e);
127 |         return {
128 |           error: createRRError(RR_ERROR_CODES.INTERNAL, `Save screenshot failed: ${message}`),
129 |         };
130 |       }
131 |     },
132 |   };
133 | }
134 | 
135 | /**
136 |  * 工件策略执行器
137 |  * @description 根据策略配置决定是否获取工件
138 |  */
139 | export interface ArtifactPolicyExecutor {
140 |   /**
141 |    * 执行截图策略
142 |    * @param policy 截图策略
143 |    * @param context 上下文
144 |    */
145 |   executeScreenshotPolicy(
146 |     policy: 'never' | 'onFailure' | 'always',
147 |     context: {
148 |       tabId: number;
149 |       runId: RunId;
150 |       nodeId: NodeId;
151 |       failed: boolean;
152 |       saveAs?: string;
153 |     },
154 |   ): Promise<{ captured: boolean; savedAs?: string; error?: RRError }>;
155 | }
156 | 
157 | /**
158 |  * 创建默认的工件策略执行器
159 |  */
160 | export function createArtifactPolicyExecutor(service: ArtifactService): ArtifactPolicyExecutor {
161 |   return {
162 |     executeScreenshotPolicy: async (policy, context) => {
163 |       // 根据策略决定是否截图
164 |       const shouldCapture = policy === 'always' || (policy === 'onFailure' && context.failed);
165 | 
166 |       if (!shouldCapture) {
167 |         return { captured: false };
168 |       }
169 | 
170 |       // 截图
171 |       const result = await service.screenshot(context.tabId);
172 |       if (!result.ok) {
173 |         return { captured: false, error: result.error };
174 |       }
175 | 
176 |       // 保存(如果指定了文件名)
177 |       if (context.saveAs) {
178 |         const saveResult = await service.saveScreenshot(
179 |           context.runId,
180 |           context.nodeId,
181 |           result.base64,
182 |           context.saveAs,
183 |         );
184 |         if ('error' in saveResult) {
185 |           return { captured: true, error: saveResult.error };
186 |         }
187 |         return { captured: true, savedAs: saveResult.savedAs };
188 |       }
189 | 
190 |       return { captured: true };
191 |     },
192 |   };
193 | }
194 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/shared/quick-panel/ui/quick-entries.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Quick Panel Quick Entries
  3 |  *
  4 |  * Four-grid shortcuts for quickly switching scopes:
  5 |  * - Tabs / Bookmarks / History / Commands
  6 |  *
  7 |  * Following PRD spec for Quick Panel entry UI.
  8 |  */
  9 | 
 10 | import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
 11 | import { QUICK_PANEL_SCOPES, normalizeQuickPanelScope, type QuickPanelScope } from '../core/types';
 12 | 
 13 | // ============================================================
 14 | // Types
 15 | // ============================================================
 16 | 
 17 | export interface QuickEntriesOptions {
 18 |   /** Container to mount quick entries */
 19 |   container: HTMLElement;
 20 |   /**
 21 |    * Scopes to render as quick entries.
 22 |    * Default: tabs/bookmarks/history/commands
 23 |    */
 24 |   scopes?: readonly QuickPanelScope[];
 25 |   /** Called when an entry is selected */
 26 |   onSelect: (scope: QuickPanelScope) => void;
 27 | }
 28 | 
 29 | export interface QuickEntriesManager {
 30 |   /** Root DOM element */
 31 |   root: HTMLDivElement;
 32 |   /** Set the active (highlighted) scope */
 33 |   setActiveScope: (scope: QuickPanelScope | null) => void;
 34 |   /** Enable/disable a specific entry */
 35 |   setDisabled: (scope: QuickPanelScope, disabled: boolean) => void;
 36 |   /** Show/hide the quick entries grid */
 37 |   setVisible: (visible: boolean) => void;
 38 |   /** Clean up resources */
 39 |   dispose: () => void;
 40 | }
 41 | 
 42 | // ============================================================
 43 | // Constants
 44 | // ============================================================
 45 | 
 46 | const DEFAULT_SCOPES: QuickPanelScope[] = ['tabs', 'bookmarks', 'history', 'commands'];
 47 | 
 48 | // ============================================================
 49 | // Main Factory
 50 | // ============================================================
 51 | 
 52 | /**
 53 |  * Create Quick Panel quick entries component.
 54 |  *
 55 |  * @example
 56 |  * ```typescript
 57 |  * const quickEntries = createQuickEntries({
 58 |  *   container: contentSearchMount,
 59 |  *   onSelect: (scope) => {
 60 |  *     searchInput.setScope(scope);
 61 |  *     controller.search(scope, '');
 62 |  *   },
 63 |  * });
 64 |  *
 65 |  * // Highlight active scope
 66 |  * quickEntries.setActiveScope('tabs');
 67 |  *
 68 |  * // Cleanup
 69 |  * quickEntries.dispose();
 70 |  * ```
 71 |  */
 72 | export function createQuickEntries(options: QuickEntriesOptions): QuickEntriesManager {
 73 |   const disposer = new Disposer();
 74 |   let disposed = false;
 75 | 
 76 |   const scopes = (options.scopes?.length ? [...options.scopes] : DEFAULT_SCOPES).map((s) =>
 77 |     normalizeQuickPanelScope(s),
 78 |   );
 79 | 
 80 |   // --------------------------------------------------------
 81 |   // DOM Construction
 82 |   // --------------------------------------------------------
 83 | 
 84 |   const root = document.createElement('div');
 85 |   root.className = 'qp-entries';
 86 |   options.container.append(root);
 87 |   disposer.add(() => root.remove());
 88 | 
 89 |   const buttonsByScope = new Map<QuickPanelScope, HTMLButtonElement>();
 90 | 
 91 |   function createEntry(scope: QuickPanelScope): HTMLButtonElement {
 92 |     const def = QUICK_PANEL_SCOPES[scope];
 93 | 
 94 |     const btn = document.createElement('button');
 95 |     btn.type = 'button';
 96 |     btn.className = 'qp-entry ac-btn ac-focus-ring';
 97 |     btn.dataset.scope = scope;
 98 |     btn.dataset.active = 'false';
 99 |     btn.setAttribute('aria-label', `Switch scope to ${def.label}`);
100 | 
101 |     const icon = document.createElement('div');
102 |     icon.className = 'qp-entry__icon';
103 |     icon.textContent = def.icon;
104 | 
105 |     const label = document.createElement('div');
106 |     label.className = 'qp-entry__label';
107 |     label.textContent = def.label;
108 | 
109 |     const prefix = document.createElement('div');
110 |     prefix.className = 'qp-entry__prefix';
111 |     prefix.textContent = def.prefix ? def.prefix.trim() : '';
112 |     prefix.hidden = !def.prefix;
113 | 
114 |     btn.append(icon, label, prefix);
115 | 
116 |     disposer.listen(btn, 'click', () => {
117 |       if (disposed) return;
118 |       options.onSelect(scope);
119 |     });
120 | 
121 |     return btn;
122 |   }
123 | 
124 |   // Build entries
125 |   for (const scope of scopes) {
126 |     // Only render known scopes and avoid 'all' in quick entries
127 |     if (!(scope in QUICK_PANEL_SCOPES) || scope === 'all') continue;
128 | 
129 |     const btn = createEntry(scope);
130 |     buttonsByScope.set(scope, btn);
131 |     root.append(btn);
132 |   }
133 | 
134 |   // --------------------------------------------------------
135 |   // State Management
136 |   // --------------------------------------------------------
137 | 
138 |   function setActiveScope(scope: QuickPanelScope | null): void {
139 |     if (disposed) return;
140 | 
141 |     const active = scope ? normalizeQuickPanelScope(scope) : null;
142 |     for (const [id, btn] of buttonsByScope) {
143 |       btn.dataset.active = active === id ? 'true' : 'false';
144 |     }
145 |   }
146 | 
147 |   function setDisabled(scope: QuickPanelScope, disabled: boolean): void {
148 |     if (disposed) return;
149 | 
150 |     const id = normalizeQuickPanelScope(scope);
151 |     const btn = buttonsByScope.get(id);
152 |     if (!btn) return;
153 | 
154 |     btn.disabled = disabled;
155 |   }
156 | 
157 |   function setVisible(visible: boolean): void {
158 |     if (disposed) return;
159 |     root.hidden = !visible;
160 |   }
161 | 
162 |   // --------------------------------------------------------
163 |   // Public API
164 |   // --------------------------------------------------------
165 | 
166 |   return {
167 |     root,
168 |     setActiveScope,
169 |     setDisabled,
170 |     setVisible,
171 |     dispose: () => {
172 |       if (disposed) return;
173 |       disposed = true;
174 |       buttonsByScope.clear();
175 |       disposer.dispose();
176 |     },
177 |   };
178 | }
179 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/keyboard.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 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
  5 | import { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';
  6 | 
  7 | interface KeyboardToolParams {
  8 |   keys: string; // Required: string representing keys or key combinations to simulate (e.g., "Enter", "Ctrl+C")
  9 |   selector?: string; // Optional: CSS selector or XPath for target element to send keyboard events to
 10 |   selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')
 11 |   delay?: number; // Optional: delay between keystrokes in milliseconds
 12 |   tabId?: number; // target existing tab id
 13 |   windowId?: number; // when no tabId, pick active tab from this window
 14 |   frameId?: number; // target frame id for iframe support
 15 | }
 16 | 
 17 | /**
 18 |  * Tool for simulating keyboard input on web pages
 19 |  */
 20 | class KeyboardTool extends BaseBrowserToolExecutor {
 21 |   name = TOOL_NAMES.BROWSER.KEYBOARD;
 22 | 
 23 |   /**
 24 |    * Execute keyboard operation
 25 |    */
 26 |   async execute(args: KeyboardToolParams): Promise<ToolResult> {
 27 |     const { keys, selector, selectorType = 'css', delay = TIMEOUTS.KEYBOARD_DELAY } = args;
 28 | 
 29 |     console.log(`Starting keyboard operation with options:`, args);
 30 | 
 31 |     if (!keys) {
 32 |       return createErrorResponse(
 33 |         ERROR_MESSAGES.INVALID_PARAMETERS + ': Keys parameter must be provided',
 34 |       );
 35 |     }
 36 | 
 37 |     try {
 38 |       const explicit = await this.tryGetTab(args.tabId);
 39 |       const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));
 40 |       if (!tab.id) {
 41 |         return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
 42 |       }
 43 | 
 44 |       let finalSelector = selector;
 45 |       let refForFocus: string | undefined = undefined;
 46 | 
 47 |       // Ensure helper is loaded for XPath or potential focus operations
 48 |       await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 49 | 
 50 |       // If selector is XPath, convert to ref then try to get CSS selector
 51 |       if (selector && selectorType === 'xpath') {
 52 |         try {
 53 |           // First convert XPath to ref
 54 |           const ensured = await this.sendMessageToTab(tab.id, {
 55 |             action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
 56 |             selector,
 57 |             isXPath: true,
 58 |           });
 59 |           if (!ensured || !ensured.success || !ensured.ref) {
 60 |             return createErrorResponse(
 61 |               `Failed to resolve XPath selector: ${ensured?.error || 'unknown error'}`,
 62 |             );
 63 |           }
 64 |           refForFocus = ensured.ref;
 65 |           // Try to resolve ref to CSS selector
 66 |           const resolved = await this.sendMessageToTab(tab.id, {
 67 |             action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 68 |             ref: ensured.ref,
 69 |           });
 70 |           if (resolved && resolved.success && resolved.selector) {
 71 |             finalSelector = resolved.selector;
 72 |             refForFocus = undefined; // Prefer CSS selector if available
 73 |           }
 74 |           // If no CSS selector available, we'll use ref to focus below
 75 |         } catch (error) {
 76 |           return createErrorResponse(
 77 |             `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`,
 78 |           );
 79 |         }
 80 |       }
 81 | 
 82 |       // If we have a ref but no CSS selector, focus the element via helper
 83 |       if (refForFocus) {
 84 |         const focusResult = await this.sendMessageToTab(tab.id, {
 85 |           action: 'focusByRef',
 86 |           ref: refForFocus,
 87 |         });
 88 |         if (focusResult && !focusResult.success) {
 89 |           return createErrorResponse(
 90 |             `Failed to focus element by ref: ${focusResult.error || 'unknown error'}`,
 91 |           );
 92 |         }
 93 |         // Clear selector so keyboard events go to the focused element
 94 |         finalSelector = undefined;
 95 |       }
 96 | 
 97 |       const frameIds = typeof args.frameId === 'number' ? [args.frameId] : undefined;
 98 |       await this.injectContentScript(
 99 |         tab.id,
100 |         ['inject-scripts/keyboard-helper.js'],
101 |         false,
102 |         'ISOLATED',
103 |         false,
104 |         frameIds,
105 |       );
106 | 
107 |       // Send keyboard simulation message to content script
108 |       const result = await this.sendMessageToTab(
109 |         tab.id,
110 |         {
111 |           action: TOOL_MESSAGE_TYPES.SIMULATE_KEYBOARD,
112 |           keys,
113 |           selector: finalSelector,
114 |           delay,
115 |         },
116 |         args.frameId,
117 |       );
118 | 
119 |       if (result.error) {
120 |         return createErrorResponse(result.error);
121 |       }
122 | 
123 |       return {
124 |         content: [
125 |           {
126 |             type: 'text',
127 |             text: JSON.stringify({
128 |               success: true,
129 |               message: result.message || 'Keyboard operation successful',
130 |               targetElement: result.targetElement,
131 |               results: result.results,
132 |             }),
133 |           },
134 |         ],
135 |         isError: false,
136 |       };
137 |     } catch (error) {
138 |       console.error('Error in keyboard operation:', error);
139 |       return createErrorResponse(
140 |         `Error simulating keyboard events: ${error instanceof Error ? error.message : String(error)}`,
141 |       );
142 |     }
143 |   }
144 | }
145 | 
146 | export const keyboardTool = new KeyboardTool();
147 | 
```

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

```typescript
  1 | /**
  2 |  * Record & Replay Core Types
  3 |  *
  4 |  * This file contains the core type definitions for the record-replay system.
  5 |  * Legacy Step types have been moved to ./legacy-types.ts and are re-exported
  6 |  * here for backward compatibility.
  7 |  *
  8 |  * Type system architecture:
  9 |  * - Legacy types (./legacy-types.ts): Step-based execution model (being phased out)
 10 |  * - Action types (./actions/types.ts): DAG-based execution model (new standard)
 11 |  * - Core types (this file): Flow, Node, Edge, Run records (shared by both)
 12 |  */
 13 | 
 14 | import { NODE_TYPES } from '@/common/node-types';
 15 | 
 16 | // =============================================================================
 17 | // Re-export Legacy Types for Backward Compatibility
 18 | // =============================================================================
 19 | 
 20 | export type {
 21 |   // Selector types
 22 |   SelectorType,
 23 |   SelectorCandidate,
 24 |   TargetLocator,
 25 |   // Step types
 26 |   StepType,
 27 |   StepBase,
 28 |   StepClick,
 29 |   StepFill,
 30 |   StepTriggerEvent,
 31 |   StepSetAttribute,
 32 |   StepScreenshot,
 33 |   StepSwitchFrame,
 34 |   StepLoopElements,
 35 |   StepKey,
 36 |   StepScroll,
 37 |   StepDrag,
 38 |   StepWait,
 39 |   StepAssert,
 40 |   StepScript,
 41 |   StepIf,
 42 |   StepForeach,
 43 |   StepWhile,
 44 |   StepHttp,
 45 |   StepExtract,
 46 |   StepOpenTab,
 47 |   StepSwitchTab,
 48 |   StepCloseTab,
 49 |   StepNavigate,
 50 |   StepHandleDownload,
 51 |   StepExecuteFlow,
 52 |   Step,
 53 | } from './legacy-types';
 54 | 
 55 | // Import Step type for use in Flow interface
 56 | import type { Step } from './legacy-types';
 57 | 
 58 | // =============================================================================
 59 | // Variable Definitions
 60 | // =============================================================================
 61 | 
 62 | export type VariableType = 'string' | 'number' | 'boolean' | 'enum' | 'array';
 63 | 
 64 | export interface VariableDef {
 65 |   key: string;
 66 |   label?: string;
 67 |   sensitive?: boolean;
 68 |   // default value can be string/number/boolean/array depending on type
 69 |   default?: any; // keep broad for backward compatibility
 70 |   type?: VariableType; // default to 'string' when omitted
 71 |   rules?: { required?: boolean; pattern?: string; enum?: string[] };
 72 | }
 73 | 
 74 | // =============================================================================
 75 | // DAG Node and Edge Types (Flow V2)
 76 | // =============================================================================
 77 | 
 78 | export type NodeType = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];
 79 | 
 80 | export interface NodeBase {
 81 |   id: string;
 82 |   type: NodeType;
 83 |   name?: string;
 84 |   disabled?: boolean;
 85 |   config?: any;
 86 |   ui?: { x: number; y: number };
 87 | }
 88 | 
 89 | export interface Edge {
 90 |   id: string;
 91 |   from: string;
 92 |   to: string;
 93 |   // label identifies the logical branch. Keep 'default' for linear/main path.
 94 |   // For conditionals, use arbitrary strings like 'case:<id>' or 'else'.
 95 |   label?: string;
 96 | }
 97 | 
 98 | // =============================================================================
 99 | // Flow Definition
100 | // =============================================================================
101 | 
102 | export interface Flow {
103 |   id: string;
104 |   name: string;
105 |   description?: string;
106 |   version: number;
107 |   meta?: {
108 |     createdAt: string;
109 |     updatedAt: string;
110 |     domain?: string;
111 |     tags?: string[];
112 |     bindings?: Array<{ type: 'domain' | 'path' | 'url'; value: string }>;
113 |     tool?: { category?: string; description?: string };
114 |     exposedOutputs?: Array<{ nodeId: string; as: string }>;
115 |     /** Recording stop barrier status (used during recording stop) */
116 |     stopBarrier?: {
117 |       ok: boolean;
118 |       sessionId?: string;
119 |       stoppedAt?: string;
120 |       failed?: Array<{
121 |         tabId: number;
122 |         skipped?: boolean;
123 |         reason?: string;
124 |         topTimedOut?: boolean;
125 |         topError?: string;
126 |         subframesFailed?: number;
127 |       }>;
128 |     };
129 |   };
130 |   variables?: VariableDef[];
131 |   /**
132 |    * @deprecated Use nodes/edges instead. This field is no longer written to storage.
133 |    * Kept as optional for backward compatibility with existing flows and imports.
134 |    */
135 |   steps?: Step[];
136 |   // Flow V2: DAG-based execution model
137 |   nodes?: NodeBase[];
138 |   edges?: Edge[];
139 |   subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;
140 | }
141 | 
142 | // =============================================================================
143 | // Run Records and Results
144 | // =============================================================================
145 | 
146 | export interface RunLogEntry {
147 |   stepId: string;
148 |   status: 'success' | 'failed' | 'retrying' | 'warning';
149 |   message?: string;
150 |   tookMs?: number;
151 |   screenshotBase64?: string; // small thumbnail (optional)
152 |   consoleSnippets?: string[]; // critical lines
153 |   networkSnippets?: Array<{ method: string; url: string; status?: number; ms?: number }>;
154 |   // selector fallback info
155 |   fallbackUsed?: boolean;
156 |   fallbackFrom?: string;
157 |   fallbackTo?: string;
158 | }
159 | 
160 | export interface RunRecord {
161 |   id: string;
162 |   flowId: string;
163 |   startedAt: string;
164 |   finishedAt?: string;
165 |   success?: boolean;
166 |   entries: RunLogEntry[];
167 | }
168 | 
169 | export interface RunResult {
170 |   runId: string;
171 |   success: boolean;
172 |   summary: { total: number; success: number; failed: number; tookMs: number };
173 |   url?: string | null;
174 |   outputs?: Record<string, any> | null;
175 |   logs?: RunLogEntry[];
176 |   screenshots?: { onFailure?: string | null };
177 |   paused?: boolean; // when true, the run was intentionally paused (e.g., breakpoint)
178 | }
179 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/agent/db/schema.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Drizzle ORM Schema for Agent Storage.
  3 |  *
  4 |  * Design principles:
  5 |  * - Type-safe database access
  6 |  * - Consistent with shared types (AgentProject, AgentStoredMessage)
  7 |  * - Proper indexes for common query patterns
  8 |  * - Foreign key constraints with cascade delete
  9 |  */
 10 | import { sqliteTable, text, index } from 'drizzle-orm/sqlite-core';
 11 | 
 12 | // ============================================================
 13 | // Projects Table
 14 | // ============================================================
 15 | 
 16 | export const projects = sqliteTable(
 17 |   'projects',
 18 |   {
 19 |     id: text().primaryKey(),
 20 |     name: text().notNull(),
 21 |     description: text(),
 22 |     rootPath: text('root_path').notNull(),
 23 |     preferredCli: text('preferred_cli'),
 24 |     selectedModel: text('selected_model'),
 25 |     /**
 26 |      * Active Claude session ID (UUID format) for session resumption.
 27 |      * Captured from SDK's system/init message.
 28 |      */
 29 |     activeClaudeSessionId: text('active_claude_session_id'),
 30 |     /**
 31 |      * Whether to use Claude Code Router (CCR) for this project.
 32 |      * Stored as '1' (true) or '0'/null (false).
 33 |      */
 34 |     useCcr: text('use_ccr'),
 35 |     /**
 36 |      * Whether to enable the local Chrome MCP server integration for this project.
 37 |      * Stored as '1' (true) or '0' (false). Default: '1' (enabled).
 38 |      */
 39 |     enableChromeMcp: text('enable_chrome_mcp').notNull().default('1'),
 40 |     createdAt: text('created_at').notNull(),
 41 |     updatedAt: text('updated_at').notNull(),
 42 |     lastActiveAt: text('last_active_at'),
 43 |   },
 44 |   (table) => ({
 45 |     lastActiveIdx: index('projects_last_active_idx').on(table.lastActiveAt),
 46 |   }),
 47 | );
 48 | 
 49 | // ============================================================
 50 | // Sessions Table
 51 | // ============================================================
 52 | 
 53 | export const sessions = sqliteTable(
 54 |   'sessions',
 55 |   {
 56 |     id: text().primaryKey(),
 57 |     projectId: text('project_id')
 58 |       .notNull()
 59 |       .references(() => projects.id, { onDelete: 'cascade' }),
 60 |     /**
 61 |      * Engine name: claude, codex, cursor, qwen, glm, etc.
 62 |      */
 63 |     engineName: text('engine_name').notNull(),
 64 |     /**
 65 |      * Engine-specific session ID for resumption.
 66 |      * For Claude: SDK's session_id from system:init message.
 67 |      */
 68 |     engineSessionId: text('engine_session_id'),
 69 |     /**
 70 |      * User-defined session name for display.
 71 |      */
 72 |     name: text(),
 73 |     /**
 74 |      * Model override for this session.
 75 |      */
 76 |     model: text(),
 77 |     /**
 78 |      * Permission mode: default, acceptEdits, bypassPermissions, plan, dontAsk.
 79 |      */
 80 |     permissionMode: text('permission_mode').notNull().default('bypassPermissions'),
 81 |     /**
 82 |      * Whether to allow bypassing interactive permission prompts.
 83 |      * Stored as '1' (true) or null (false).
 84 |      */
 85 |     allowDangerouslySkipPermissions: text('allow_dangerously_skip_permissions'),
 86 |     /**
 87 |      * JSON: System prompt configuration.
 88 |      * Format: { type: 'custom', text: string } | { type: 'preset', preset: 'claude_code', append?: string }
 89 |      */
 90 |     systemPromptConfig: text('system_prompt_config'),
 91 |     /**
 92 |      * JSON: Engine/session option overrides (settingSources, tools, betas, etc.).
 93 |      */
 94 |     optionsConfig: text('options_config'),
 95 |     /**
 96 |      * JSON: Cached management info (supported models, commands, account, MCP servers, etc.).
 97 |      */
 98 |     managementInfo: text('management_info'),
 99 |     createdAt: text('created_at').notNull(),
100 |     updatedAt: text('updated_at').notNull(),
101 |   },
102 |   (table) => ({
103 |     projectIdIdx: index('sessions_project_id_idx').on(table.projectId),
104 |     engineNameIdx: index('sessions_engine_name_idx').on(table.engineName),
105 |   }),
106 | );
107 | 
108 | // ============================================================
109 | // Messages Table
110 | // ============================================================
111 | 
112 | export const messages = sqliteTable(
113 |   'messages',
114 |   {
115 |     id: text().primaryKey(),
116 |     projectId: text('project_id')
117 |       .notNull()
118 |       .references(() => projects.id, { onDelete: 'cascade' }),
119 |     sessionId: text('session_id').notNull(),
120 |     conversationId: text('conversation_id'),
121 |     role: text().notNull(), // 'user' | 'assistant' | 'tool' | 'system'
122 |     content: text().notNull(),
123 |     messageType: text('message_type').notNull(), // 'chat' | 'tool_use' | 'tool_result' | 'status'
124 |     metadata: text(), // JSON string
125 |     cliSource: text('cli_source'),
126 |     requestId: text('request_id'),
127 |     createdAt: text('created_at').notNull(),
128 |   },
129 |   (table) => ({
130 |     projectIdIdx: index('messages_project_id_idx').on(table.projectId),
131 |     sessionIdIdx: index('messages_session_id_idx').on(table.sessionId),
132 |     createdAtIdx: index('messages_created_at_idx').on(table.createdAt),
133 |     requestIdIdx: index('messages_request_id_idx').on(table.requestId),
134 |   }),
135 | );
136 | 
137 | // ============================================================
138 | // Type Inference Helpers
139 | // ============================================================
140 | 
141 | export type ProjectRow = typeof projects.$inferSelect;
142 | export type ProjectInsert = typeof projects.$inferInsert;
143 | export type SessionRow = typeof sessions.$inferSelect;
144 | export type SessionInsert = typeof sessions.$inferInsert;
145 | export type MessageRow = typeof messages.$inferSelect;
146 | export type MessageInsert = typeof messages.$inferInsert;
147 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/network-capture.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 | import { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';
  5 | import { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';
  6 | 
  7 | type NetworkCaptureBackend = 'webRequest' | 'debugger';
  8 | 
  9 | interface NetworkCaptureToolParams {
 10 |   action: 'start' | 'stop';
 11 |   needResponseBody?: boolean;
 12 |   url?: string;
 13 |   maxCaptureTime?: number;
 14 |   inactivityTimeout?: number;
 15 |   includeStatic?: boolean;
 16 | }
 17 | 
 18 | /**
 19 |  * Extract text content from ToolResult
 20 |  */
 21 | function getFirstText(result: ToolResult): string | undefined {
 22 |   const first = result.content?.[0];
 23 |   return first && first.type === 'text' ? first.text : undefined;
 24 | }
 25 | 
 26 | /**
 27 |  * Decorate JSON result with additional fields
 28 |  */
 29 | function decorateJsonResult(result: ToolResult, extra: Record<string, unknown>): ToolResult {
 30 |   const text = getFirstText(result);
 31 |   if (typeof text !== 'string') return result;
 32 | 
 33 |   try {
 34 |     const parsed = JSON.parse(text);
 35 |     if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
 36 |       return {
 37 |         ...result,
 38 |         content: [{ type: 'text', text: JSON.stringify({ ...parsed, ...extra }) }],
 39 |       };
 40 |     }
 41 |   } catch {
 42 |     // If the underlying tool didn't return JSON, keep it as-is
 43 |   }
 44 |   return result;
 45 | }
 46 | 
 47 | /**
 48 |  * Check if debugger-based capture is active
 49 |  */
 50 | function isDebuggerCaptureActive(): boolean {
 51 |   const captureData = (
 52 |     networkDebuggerStartTool as unknown as { captureData?: Map<number, unknown> }
 53 |   ).captureData;
 54 |   return captureData instanceof Map && captureData.size > 0;
 55 | }
 56 | 
 57 | /**
 58 |  * Check if webRequest-based capture is active
 59 |  */
 60 | function isWebRequestCaptureActive(): boolean {
 61 |   return networkCaptureStartTool.captureData.size > 0;
 62 | }
 63 | 
 64 | /**
 65 |  * Unified Network Capture Tool
 66 |  *
 67 |  * Provides a single entry point for network capture, automatically selecting
 68 |  * the appropriate backend based on the `needResponseBody` parameter:
 69 |  * - needResponseBody=false (default): uses webRequest API (lightweight, no debugger conflict)
 70 |  * - needResponseBody=true: uses Debugger API (captures response body, may conflict with DevTools)
 71 |  */
 72 | class NetworkCaptureTool extends BaseBrowserToolExecutor {
 73 |   name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE;
 74 | 
 75 |   async execute(args: NetworkCaptureToolParams): Promise<ToolResult> {
 76 |     const action = args?.action;
 77 |     if (action !== 'start' && action !== 'stop') {
 78 |       return createErrorResponse('Parameter [action] is required and must be one of: start, stop');
 79 |     }
 80 | 
 81 |     const wantBody = args?.needResponseBody === true;
 82 |     const debuggerActive = isDebuggerCaptureActive();
 83 |     const webActive = isWebRequestCaptureActive();
 84 | 
 85 |     if (action === 'start') {
 86 |       return this.handleStart(args, wantBody, debuggerActive, webActive);
 87 |     }
 88 | 
 89 |     return this.handleStop(args, debuggerActive, webActive);
 90 |   }
 91 | 
 92 |   private async handleStart(
 93 |     args: NetworkCaptureToolParams,
 94 |     wantBody: boolean,
 95 |     debuggerActive: boolean,
 96 |     webActive: boolean,
 97 |   ): Promise<ToolResult> {
 98 |     // Prevent any capture conflict (cross-mode or same-mode)
 99 |     if (debuggerActive || webActive) {
100 |       const activeMode = debuggerActive ? 'debugger' : 'webRequest';
101 |       return createErrorResponse(
102 |         `Network capture is already active in ${activeMode} mode. Stop it before starting a new capture.`,
103 |       );
104 |     }
105 | 
106 |     const delegate = wantBody ? networkDebuggerStartTool : networkCaptureStartTool;
107 |     const backend: NetworkCaptureBackend = wantBody ? 'debugger' : 'webRequest';
108 | 
109 |     const result = await delegate.execute({
110 |       url: args.url,
111 |       maxCaptureTime: args.maxCaptureTime,
112 |       inactivityTimeout: args.inactivityTimeout,
113 |       includeStatic: args.includeStatic,
114 |     });
115 | 
116 |     return decorateJsonResult(result, { backend, needResponseBody: wantBody });
117 |   }
118 | 
119 |   private async handleStop(
120 |     args: NetworkCaptureToolParams,
121 |     debuggerActive: boolean,
122 |     webActive: boolean,
123 |   ): Promise<ToolResult> {
124 |     // Determine which backend to stop
125 |     let backendToStop: NetworkCaptureBackend | null = null;
126 | 
127 |     // If user explicitly specified needResponseBody, try to stop that specific backend
128 |     if (args?.needResponseBody === true) {
129 |       backendToStop = debuggerActive ? 'debugger' : null;
130 |     } else if (args?.needResponseBody === false) {
131 |       backendToStop = webActive ? 'webRequest' : null;
132 |     }
133 | 
134 |     // If no explicit preference or the specified backend isn't active, auto-detect
135 |     if (!backendToStop) {
136 |       if (debuggerActive) {
137 |         backendToStop = 'debugger';
138 |       } else if (webActive) {
139 |         backendToStop = 'webRequest';
140 |       }
141 |     }
142 | 
143 |     if (!backendToStop) {
144 |       return createErrorResponse('No active network captures found in any tab.');
145 |     }
146 | 
147 |     const delegateStop =
148 |       backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool;
149 |     const result = await delegateStop.execute();
150 | 
151 |     return decorateJsonResult(result, {
152 |       backend: backendToStop,
153 |       needResponseBody: backendToStop === 'debugger',
154 |     });
155 |   }
156 | }
157 | 
158 | export const networkCaptureTool = new NetworkCaptureTool();
159 | 
```

--------------------------------------------------------------------------------
/app/native-server/src/mcp/register-tools.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
  2 | import {
  3 |   CallToolRequestSchema,
  4 |   CallToolResult,
  5 |   ListToolsRequestSchema,
  6 | } from '@modelcontextprotocol/sdk/types.js';
  7 | import nativeMessagingHostInstance from '../native-messaging-host';
  8 | import { NativeMessageType, TOOL_SCHEMAS } from 'chrome-mcp-shared';
  9 | import type { Tool } from '@modelcontextprotocol/sdk/types.js';
 10 | 
 11 | async function listDynamicFlowTools(): Promise<Tool[]> {
 12 |   try {
 13 |     const response = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(
 14 |       {},
 15 |       'rr_list_published_flows',
 16 |       20000,
 17 |     );
 18 |     if (response && response.status === 'success' && Array.isArray(response.items)) {
 19 |       const tools: Tool[] = [];
 20 |       for (const item of response.items) {
 21 |         const name = `flow.${item.slug}`;
 22 |         const description =
 23 |           (item.meta && item.meta.tool && item.meta.tool.description) ||
 24 |           item.description ||
 25 |           'Recorded flow';
 26 |         const properties: Record<string, any> = {};
 27 |         const required: string[] = [];
 28 |         for (const v of item.variables || []) {
 29 |           const desc = v.label || v.key;
 30 |           const typ = (v.type || 'string').toLowerCase();
 31 |           const prop: any = { description: desc };
 32 |           if (typ === 'boolean') prop.type = 'boolean';
 33 |           else if (typ === 'number') prop.type = 'number';
 34 |           else if (typ === 'enum') {
 35 |             prop.type = 'string';
 36 |             if (v.rules && Array.isArray(v.rules.enum)) prop.enum = v.rules.enum;
 37 |           } else if (typ === 'array') {
 38 |             // default array of strings; can extend with itemType later
 39 |             prop.type = 'array';
 40 |             prop.items = { type: 'string' };
 41 |           } else {
 42 |             prop.type = 'string';
 43 |           }
 44 |           if (v.default !== undefined) prop.default = v.default;
 45 |           if (v.rules && v.rules.required) required.push(v.key);
 46 |           properties[v.key] = prop;
 47 |         }
 48 |         // Run options
 49 |         properties['tabTarget'] = { type: 'string', enum: ['current', 'new'], default: 'current' };
 50 |         properties['refresh'] = { type: 'boolean', default: false };
 51 |         properties['captureNetwork'] = { type: 'boolean', default: false };
 52 |         properties['returnLogs'] = { type: 'boolean', default: false };
 53 |         properties['timeoutMs'] = { type: 'number', minimum: 0 };
 54 |         const tool: Tool = {
 55 |           name,
 56 |           description,
 57 |           inputSchema: { type: 'object', properties, required },
 58 |         };
 59 |         tools.push(tool);
 60 |       }
 61 |       return tools;
 62 |     }
 63 |     return [];
 64 |   } catch (e) {
 65 |     return [];
 66 |   }
 67 | }
 68 | 
 69 | export const setupTools = (server: Server) => {
 70 |   // List tools handler
 71 |   server.setRequestHandler(ListToolsRequestSchema, async () => {
 72 |     const dynamicTools = await listDynamicFlowTools();
 73 |     return { tools: [...TOOL_SCHEMAS, ...dynamicTools] };
 74 |   });
 75 | 
 76 |   // Call tool handler
 77 |   server.setRequestHandler(CallToolRequestSchema, async (request) =>
 78 |     handleToolCall(request.params.name, request.params.arguments || {}),
 79 |   );
 80 | };
 81 | 
 82 | const handleToolCall = async (name: string, args: any): Promise<CallToolResult> => {
 83 |   try {
 84 |     // If calling a dynamic flow tool (name starts with flow.), proxy to common flow-run tool
 85 |     if (name && name.startsWith('flow.')) {
 86 |       // We need to resolve flow by slug to ID
 87 |       try {
 88 |         const resp = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(
 89 |           {},
 90 |           'rr_list_published_flows',
 91 |           20000,
 92 |         );
 93 |         const items = (resp && resp.items) || [];
 94 |         const slug = name.slice('flow.'.length);
 95 |         const match = items.find((it: any) => it.slug === slug);
 96 |         if (!match) throw new Error(`Flow not found for tool ${name}`);
 97 |         const flowArgs = { flowId: match.id, args };
 98 |         const proxyRes = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(
 99 |           { name: 'record_replay_flow_run', args: flowArgs },
100 |           NativeMessageType.CALL_TOOL,
101 |           120000,
102 |         );
103 |         if (proxyRes.status === 'success') return proxyRes.data;
104 |         return {
105 |           content: [{ type: 'text', text: `Error calling dynamic flow tool: ${proxyRes.error}` }],
106 |           isError: true,
107 |         };
108 |       } catch (err: any) {
109 |         return {
110 |           content: [
111 |             {
112 |               type: 'text',
113 |               text: `Error resolving dynamic flow tool: ${err?.message || String(err)}`,
114 |             },
115 |           ],
116 |           isError: true,
117 |         };
118 |       }
119 |     }
120 |     // 发送请求到Chrome扩展并等待响应
121 |     const response = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(
122 |       {
123 |         name,
124 |         args,
125 |       },
126 |       NativeMessageType.CALL_TOOL,
127 |       120000, // 延长到 120 秒,避免性能分析等长任务超时
128 |     );
129 |     if (response.status === 'success') {
130 |       return response.data;
131 |     } else {
132 |       return {
133 |         content: [
134 |           {
135 |             type: 'text',
136 |             text: `Error calling tool: ${response.error}`,
137 |           },
138 |         ],
139 |         isError: true,
140 |       };
141 |     }
142 |   } catch (error: any) {
143 |     return {
144 |       content: [
145 |         {
146 |           type: 'text',
147 |           text: `Error calling tool: ${error.message}`,
148 |         },
149 |       ],
150 |       isError: true,
151 |     };
152 |   }
153 | };
154 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/offscreen/gif-encoder.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * GIF Encoder Module for Offscreen Document
  3 |  *
  4 |  * Handles GIF encoding using the gifenc library in the offscreen document context.
  5 |  * This module provides frame-by-frame GIF encoding with palette quantization.
  6 |  */
  7 | 
  8 | import { GIFEncoder, quantize, applyPalette } from 'gifenc';
  9 | import { MessageTarget, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';
 10 | 
 11 | // ============================================================================
 12 | // Types
 13 | // ============================================================================
 14 | 
 15 | interface GifEncoderState {
 16 |   encoder: ReturnType<typeof GIFEncoder> | null;
 17 |   width: number;
 18 |   height: number;
 19 |   frameCount: number;
 20 |   isInitialized: boolean;
 21 | }
 22 | 
 23 | interface GifAddFrameMessage {
 24 |   target: MessageTarget;
 25 |   type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME;
 26 |   imageData: number[];
 27 |   width: number;
 28 |   height: number;
 29 |   delay: number;
 30 |   maxColors?: number;
 31 | }
 32 | 
 33 | interface GifFinishMessage {
 34 |   target: MessageTarget;
 35 |   type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_FINISH;
 36 | }
 37 | 
 38 | interface GifResetMessage {
 39 |   target: MessageTarget;
 40 |   type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_RESET;
 41 | }
 42 | 
 43 | type GifMessage = GifAddFrameMessage | GifFinishMessage | GifResetMessage;
 44 | 
 45 | interface GifMessageResponse {
 46 |   success: boolean;
 47 |   error?: string;
 48 |   frameCount?: number;
 49 |   gifData?: number[];
 50 |   byteLength?: number;
 51 | }
 52 | 
 53 | // ============================================================================
 54 | // State
 55 | // ============================================================================
 56 | 
 57 | const state: GifEncoderState = {
 58 |   encoder: null,
 59 |   width: 0,
 60 |   height: 0,
 61 |   frameCount: 0,
 62 |   isInitialized: false,
 63 | };
 64 | 
 65 | // ============================================================================
 66 | // Handlers
 67 | // ============================================================================
 68 | 
 69 | function initializeEncoder(width: number, height: number): void {
 70 |   state.encoder = GIFEncoder();
 71 |   state.width = width;
 72 |   state.height = height;
 73 |   state.frameCount = 0;
 74 |   state.isInitialized = true;
 75 | }
 76 | 
 77 | function addFrame(
 78 |   imageData: Uint8ClampedArray,
 79 |   width: number,
 80 |   height: number,
 81 |   delay: number,
 82 |   maxColors: number = 256,
 83 | ): void {
 84 |   // Initialize encoder on first frame
 85 |   if (!state.isInitialized || state.width !== width || state.height !== height) {
 86 |     initializeEncoder(width, height);
 87 |   }
 88 | 
 89 |   if (!state.encoder) {
 90 |     throw new Error('GIF encoder not initialized');
 91 |   }
 92 | 
 93 |   // Quantize colors to create palette
 94 |   const palette = quantize(imageData, maxColors, { format: 'rgb444' });
 95 | 
 96 |   // Map pixels to palette indices
 97 |   const indexedPixels = applyPalette(imageData, palette, 'rgb444');
 98 | 
 99 |   // Write frame to encoder
100 |   state.encoder.writeFrame(indexedPixels, width, height, {
101 |     palette,
102 |     delay,
103 |     dispose: 2, // Restore to background color
104 |   });
105 | 
106 |   state.frameCount++;
107 | }
108 | 
109 | function finishEncoding(): Uint8Array {
110 |   if (!state.encoder) {
111 |     throw new Error('GIF encoder not initialized');
112 |   }
113 | 
114 |   state.encoder.finish();
115 |   const bytes = state.encoder.bytes();
116 | 
117 |   // Reset state after finishing
118 |   resetEncoder();
119 | 
120 |   return bytes;
121 | }
122 | 
123 | function resetEncoder(): void {
124 |   if (state.encoder) {
125 |     state.encoder.reset();
126 |   }
127 |   state.encoder = null;
128 |   state.width = 0;
129 |   state.height = 0;
130 |   state.frameCount = 0;
131 |   state.isInitialized = false;
132 | }
133 | 
134 | // ============================================================================
135 | // Message Handler
136 | // ============================================================================
137 | 
138 | function isGifMessage(message: unknown): message is GifMessage {
139 |   if (!message || typeof message !== 'object') return false;
140 |   const msg = message as Record<string, unknown>;
141 |   if (msg.target !== MessageTarget.Offscreen) return false;
142 | 
143 |   const gifTypes = [
144 |     OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME,
145 |     OFFSCREEN_MESSAGE_TYPES.GIF_FINISH,
146 |     OFFSCREEN_MESSAGE_TYPES.GIF_RESET,
147 |   ];
148 | 
149 |   return gifTypes.includes(msg.type as string);
150 | }
151 | 
152 | export function handleGifMessage(
153 |   message: unknown,
154 |   sendResponse: (response: GifMessageResponse) => void,
155 | ): boolean {
156 |   if (!isGifMessage(message)) {
157 |     return false;
158 |   }
159 | 
160 |   try {
161 |     switch (message.type) {
162 |       case OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME: {
163 |         const { imageData, width, height, delay, maxColors } = message;
164 |         const clampedData = new Uint8ClampedArray(imageData);
165 |         addFrame(clampedData, width, height, delay, maxColors);
166 |         sendResponse({
167 |           success: true,
168 |           frameCount: state.frameCount,
169 |         });
170 |         break;
171 |       }
172 | 
173 |       case OFFSCREEN_MESSAGE_TYPES.GIF_FINISH: {
174 |         const gifBytes = finishEncoding();
175 |         sendResponse({
176 |           success: true,
177 |           gifData: Array.from(gifBytes),
178 |           byteLength: gifBytes.byteLength,
179 |         });
180 |         break;
181 |       }
182 | 
183 |       case OFFSCREEN_MESSAGE_TYPES.GIF_RESET: {
184 |         resetEncoder();
185 |         sendResponse({ success: true });
186 |         break;
187 |       }
188 | 
189 |       default:
190 |         sendResponse({ success: false, error: `Unknown GIF message type` });
191 |     }
192 |   } catch (error) {
193 |     const errorMessage = error instanceof Error ? error.message : String(error);
194 |     console.error('GIF encoder error:', errorMessage);
195 |     sendResponse({ success: false, error: errorMessage });
196 |   }
197 | 
198 |   return true;
199 | }
200 | 
201 | console.log('GIF encoder module loaded');
202 | 
```

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

```vue
  1 | <template>
  2 |   <div v-if="visible" class="rr-modal">
  3 |     <div class="rr-dialog">
  4 |       <div class="rr-header">
  5 |         <div class="title">定时执行</div>
  6 |         <button class="close" @click="$emit('close')">✕</button>
  7 |       </div>
  8 |       <div class="rr-body">
  9 |         <div class="row">
 10 |           <label>启用</label>
 11 |           <label class="chk"><input type="checkbox" v-model="enabled" />启用定时</label>
 12 |         </div>
 13 |         <div class="row">
 14 |           <label>类型</label>
 15 |           <select v-model="type">
 16 |             <option value="interval">每隔 N 分钟</option>
 17 |             <option value="daily">每天固定时间</option>
 18 |             <option value="once">只执行一次</option>
 19 |           </select>
 20 |         </div>
 21 |         <div class="row" v-if="type === 'interval'">
 22 |           <label>间隔(分钟)</label>
 23 |           <input type="number" v-model.number="intervalMinutes" />
 24 |         </div>
 25 |         <div class="row" v-if="type === 'daily'">
 26 |           <label>时间(HH:mm)</label>
 27 |           <input v-model="dailyTime" placeholder="例如 09:30" />
 28 |         </div>
 29 |         <div class="row" v-if="type === 'once'">
 30 |           <label>时间(ISO)</label>
 31 |           <input v-model="onceAt" placeholder="例如 2025-10-05T10:00:00" />
 32 |         </div>
 33 |         <div class="row">
 34 |           <label>参数(JSON)</label>
 35 |           <textarea v-model="argsJson" placeholder='{ "username": "xx" }'></textarea>
 36 |         </div>
 37 |         <div class="section">
 38 |           <div class="section-title">已有计划</div>
 39 |           <div class="sched-list">
 40 |             <div class="sched-row" v-for="s in schedules" :key="s.id">
 41 |               <div class="meta">
 42 |                 <span class="badge" :class="{ on: s.enabled, off: !s.enabled }">{{ s.type }}</span>
 43 |                 <span class="desc">{{ describe(s) }}</span>
 44 |               </div>
 45 |               <div class="actions">
 46 |                 <button class="small danger" @click="$emit('remove', s.id)">删除</button>
 47 |               </div>
 48 |             </div>
 49 |           </div>
 50 |         </div>
 51 |       </div>
 52 |       <div class="rr-footer">
 53 |         <button class="primary" @click="save">保存</button>
 54 |       </div>
 55 |     </div>
 56 |   </div>
 57 | </template>
 58 | 
 59 | <script lang="ts" setup>
 60 | import { ref, watch } from 'vue';
 61 | 
 62 | const props = defineProps<{ visible: boolean; flowId: string | null; schedules: any[] }>();
 63 | const emit = defineEmits(['close', 'save', 'remove']);
 64 | 
 65 | const enabled = ref(true);
 66 | const type = ref<'interval' | 'daily' | 'once'>('interval');
 67 | const intervalMinutes = ref(30);
 68 | const dailyTime = ref('09:00');
 69 | const onceAt = ref('');
 70 | const argsJson = ref('');
 71 | 
 72 | watch(
 73 |   () => props.visible,
 74 |   (v) => {
 75 |     if (v) {
 76 |       enabled.value = true;
 77 |       type.value = 'interval';
 78 |       intervalMinutes.value = 30;
 79 |       dailyTime.value = '09:00';
 80 |       onceAt.value = '';
 81 |       argsJson.value = '';
 82 |     }
 83 |   },
 84 | );
 85 | 
 86 | function save() {
 87 |   if (!props.flowId) return;
 88 |   const schedule = {
 89 |     id: `sch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
 90 |     flowId: props.flowId,
 91 |     type: type.value,
 92 |     enabled: enabled.value,
 93 |     when:
 94 |       type.value === 'interval'
 95 |         ? String(intervalMinutes.value)
 96 |         : type.value === 'daily'
 97 |           ? dailyTime.value
 98 |           : onceAt.value,
 99 |     args: safeParse(argsJson.value),
100 |   } as any;
101 |   emit('save', schedule);
102 | }
103 | 
104 | function safeParse(s: string) {
105 |   if (!s || !s.trim()) return {};
106 |   try {
107 |     return JSON.parse(s);
108 |   } catch {
109 |     return {};
110 |   }
111 | }
112 | 
113 | function describe(s: any) {
114 |   if (s.type === 'interval') return `每 ${s.when} 分钟`;
115 |   if (s.type === 'daily') return `每天 ${s.when}`;
116 |   if (s.type === 'once') return `一次 ${s.when}`;
117 |   return '';
118 | }
119 | </script>
120 | 
121 | <style scoped>
122 | .rr-modal {
123 |   position: fixed;
124 |   inset: 0;
125 |   background: rgba(0, 0, 0, 0.35);
126 |   z-index: 2147483646;
127 |   display: flex;
128 |   align-items: center;
129 |   justify-content: center;
130 | }
131 | .rr-dialog {
132 |   background: #fff;
133 |   border-radius: 8px;
134 |   max-width: 720px;
135 |   width: 96vw;
136 |   max-height: 90vh;
137 |   display: flex;
138 |   flex-direction: column;
139 | }
140 | .rr-header {
141 |   display: flex;
142 |   justify-content: space-between;
143 |   align-items: center;
144 |   padding: 12px 16px;
145 |   border-bottom: 1px solid #e5e7eb;
146 | }
147 | .rr-header .title {
148 |   font-weight: 600;
149 | }
150 | .rr-header .close {
151 |   border: none;
152 |   background: #f3f4f6;
153 |   border-radius: 6px;
154 |   padding: 4px 8px;
155 |   cursor: pointer;
156 | }
157 | .rr-body {
158 |   padding: 12px 16px;
159 |   overflow: auto;
160 | }
161 | .row {
162 |   display: flex;
163 |   gap: 8px;
164 |   align-items: center;
165 |   margin: 6px 0;
166 | }
167 | .row > label {
168 |   width: 120px;
169 |   color: #374151;
170 | }
171 | .row > input,
172 | .row > textarea,
173 | .row > select {
174 |   flex: 1;
175 |   border: 1px solid #d1d5db;
176 |   border-radius: 6px;
177 |   padding: 6px 8px;
178 | }
179 | .row > textarea {
180 |   min-height: 64px;
181 | }
182 | .chk {
183 |   display: inline-flex;
184 |   gap: 6px;
185 |   align-items: center;
186 | }
187 | .sched-list .sched-row {
188 |   display: flex;
189 |   justify-content: space-between;
190 |   align-items: center;
191 |   padding: 6px 8px;
192 |   border: 1px solid #e5e7eb;
193 |   border-radius: 6px;
194 |   margin: 4px 0;
195 | }
196 | .badge {
197 |   padding: 2px 6px;
198 |   border-radius: 6px;
199 |   background: #e5e7eb;
200 | }
201 | .badge.on {
202 |   background: #dcfce7;
203 | }
204 | .badge.off {
205 |   background: #fee2e2;
206 | }
207 | .small {
208 |   font-size: 12px;
209 |   padding: 4px 8px;
210 |   border: 1px solid #d1d5db;
211 |   background: #fff;
212 |   border-radius: 6px;
213 |   cursor: pointer;
214 | }
215 | .danger {
216 |   background: #fee2e2;
217 |   border-color: #fecaca;
218 | }
219 | .primary {
220 |   background: #111;
221 |   color: #fff;
222 |   border: none;
223 |   border-radius: 6px;
224 |   padding: 8px 16px;
225 |   cursor: pointer;
226 | }
227 | .rr-footer {
228 |   padding: 12px 16px;
229 |   border-top: 1px solid #e5e7eb;
230 |   display: flex;
231 |   justify-content: flex-end;
232 | }
233 | </style>
234 | 
```

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

```vue
  1 | <template>
  2 |   <div v-if="visible" class="confirmation-dialog" @click.self="$emit('cancel')">
  3 |     <div class="dialog-content">
  4 |       <div class="dialog-header">
  5 |         <span class="dialog-icon">{{ icon }}</span>
  6 |         <h3 class="dialog-title">{{ title }}</h3>
  7 |       </div>
  8 | 
  9 |       <div class="dialog-body">
 10 |         <p class="dialog-message">{{ message }}</p>
 11 | 
 12 |         <ul v-if="items && items.length > 0" class="dialog-list">
 13 |           <li v-for="item in items" :key="item">{{ item }}</li>
 14 |         </ul>
 15 | 
 16 |         <div v-if="warning" class="dialog-warning">
 17 |           <strong>{{ warning }}</strong>
 18 |         </div>
 19 |       </div>
 20 | 
 21 |       <div class="dialog-actions">
 22 |         <button class="dialog-button cancel-button" @click="$emit('cancel')">
 23 |           {{ cancelText }}
 24 |         </button>
 25 |         <button
 26 |           class="dialog-button confirm-button"
 27 |           :disabled="isConfirming"
 28 |           @click="$emit('confirm')"
 29 |         >
 30 |           {{ isConfirming ? confirmingText : confirmText }}
 31 |         </button>
 32 |       </div>
 33 |     </div>
 34 |   </div>
 35 | </template>
 36 | 
 37 | <script lang="ts" setup>
 38 | import { getMessage } from '@/utils/i18n';
 39 | interface Props {
 40 |   visible: boolean;
 41 |   title: string;
 42 |   message: string;
 43 |   items?: string[];
 44 |   warning?: string;
 45 |   icon?: string;
 46 |   confirmText?: string;
 47 |   cancelText?: string;
 48 |   confirmingText?: string;
 49 |   isConfirming?: boolean;
 50 | }
 51 | 
 52 | interface Emits {
 53 |   (e: 'confirm'): void;
 54 |   (e: 'cancel'): void;
 55 | }
 56 | 
 57 | withDefaults(defineProps<Props>(), {
 58 |   icon: '⚠️',
 59 |   confirmText: getMessage('confirmButton'),
 60 |   cancelText: getMessage('cancelButton'),
 61 |   confirmingText: getMessage('processingStatus'),
 62 |   isConfirming: false,
 63 | });
 64 | 
 65 | defineEmits<Emits>();
 66 | </script>
 67 | 
 68 | <style scoped>
 69 | .confirmation-dialog {
 70 |   position: fixed;
 71 |   top: 0;
 72 |   left: 0;
 73 |   right: 0;
 74 |   bottom: 0;
 75 |   background: rgba(0, 0, 0, 0.6);
 76 |   display: flex;
 77 |   align-items: center;
 78 |   justify-content: center;
 79 |   z-index: 1000;
 80 |   backdrop-filter: blur(8px);
 81 |   animation: dialogFadeIn 0.3s ease-out;
 82 | }
 83 | 
 84 | @keyframes dialogFadeIn {
 85 |   from {
 86 |     opacity: 0;
 87 |     backdrop-filter: blur(0px);
 88 |   }
 89 |   to {
 90 |     opacity: 1;
 91 |     backdrop-filter: blur(8px);
 92 |   }
 93 | }
 94 | 
 95 | .dialog-content {
 96 |   background: white;
 97 |   border-radius: 12px;
 98 |   padding: 24px;
 99 |   max-width: 360px;
100 |   width: 90%;
101 |   box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
102 |   animation: dialogSlideIn 0.3s ease-out;
103 |   border: 1px solid rgba(255, 255, 255, 0.2);
104 | }
105 | 
106 | @keyframes dialogSlideIn {
107 |   from {
108 |     opacity: 0;
109 |     transform: translateY(-30px) scale(0.9);
110 |   }
111 |   to {
112 |     opacity: 1;
113 |     transform: translateY(0) scale(1);
114 |   }
115 | }
116 | 
117 | .dialog-header {
118 |   display: flex;
119 |   align-items: center;
120 |   gap: 12px;
121 |   margin-bottom: 20px;
122 | }
123 | 
124 | .dialog-icon {
125 |   font-size: 24px;
126 |   filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
127 | }
128 | 
129 | .dialog-title {
130 |   font-size: 18px;
131 |   font-weight: 600;
132 |   color: #2d3748;
133 |   margin: 0;
134 | }
135 | 
136 | .dialog-body {
137 |   margin-bottom: 24px;
138 | }
139 | 
140 | .dialog-message {
141 |   font-size: 14px;
142 |   color: #4a5568;
143 |   margin: 0 0 16px 0;
144 |   line-height: 1.6;
145 | }
146 | 
147 | .dialog-list {
148 |   margin: 16px 0;
149 |   padding-left: 24px;
150 |   background: linear-gradient(135deg, #f7fafc, #edf2f7);
151 |   border-radius: 6px;
152 |   padding: 12px 12px 12px 32px;
153 |   border-left: 3px solid #667eea;
154 | }
155 | 
156 | .dialog-list li {
157 |   font-size: 13px;
158 |   color: #718096;
159 |   margin-bottom: 6px;
160 |   line-height: 1.4;
161 | }
162 | 
163 | .dialog-list li:last-child {
164 |   margin-bottom: 0;
165 | }
166 | 
167 | .dialog-warning {
168 |   font-size: 13px;
169 |   color: #e53e3e;
170 |   margin: 16px 0 0 0;
171 |   padding: 12px;
172 |   background: linear-gradient(135deg, rgba(245, 101, 101, 0.1), rgba(229, 62, 62, 0.05));
173 |   border-radius: 6px;
174 |   border-left: 3px solid #e53e3e;
175 |   border: 1px solid rgba(245, 101, 101, 0.2);
176 | }
177 | 
178 | .dialog-actions {
179 |   display: flex;
180 |   gap: 12px;
181 |   justify-content: flex-end;
182 | }
183 | 
184 | .dialog-button {
185 |   padding: 10px 20px;
186 |   border: none;
187 |   border-radius: 8px;
188 |   font-size: 14px;
189 |   font-weight: 500;
190 |   cursor: pointer;
191 |   transition: all 0.3s ease;
192 |   min-width: 80px;
193 | }
194 | 
195 | .cancel-button {
196 |   background: linear-gradient(135deg, #e2e8f0, #cbd5e0);
197 |   color: #4a5568;
198 |   border: 1px solid #cbd5e0;
199 | }
200 | 
201 | .cancel-button:hover {
202 |   background: linear-gradient(135deg, #cbd5e0, #a0aec0);
203 |   transform: translateY(-1px);
204 |   box-shadow: 0 4px 12px rgba(160, 174, 192, 0.3);
205 | }
206 | 
207 | .confirm-button {
208 |   background: linear-gradient(135deg, #f56565, #e53e3e);
209 |   color: white;
210 |   border: 1px solid #e53e3e;
211 | }
212 | 
213 | .confirm-button:hover:not(:disabled) {
214 |   background: linear-gradient(135deg, #e53e3e, #c53030);
215 |   transform: translateY(-1px);
216 |   box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);
217 | }
218 | 
219 | .confirm-button:disabled {
220 |   opacity: 0.7;
221 |   cursor: not-allowed;
222 |   transform: none;
223 |   box-shadow: none;
224 | }
225 | 
226 | /* 响应式设计 */
227 | @media (max-width: 420px) {
228 |   .dialog-content {
229 |     padding: 20px;
230 |     max-width: 320px;
231 |   }
232 | 
233 |   .dialog-header {
234 |     gap: 10px;
235 |     margin-bottom: 16px;
236 |   }
237 | 
238 |   .dialog-icon {
239 |     font-size: 20px;
240 |   }
241 | 
242 |   .dialog-title {
243 |     font-size: 16px;
244 |   }
245 | 
246 |   .dialog-message {
247 |     font-size: 13px;
248 |   }
249 | 
250 |   .dialog-list {
251 |     padding: 10px 10px 10px 28px;
252 |   }
253 | 
254 |   .dialog-list li {
255 |     font-size: 12px;
256 |   }
257 | 
258 |   .dialog-warning {
259 |     font-size: 12px;
260 |     padding: 10px;
261 |   }
262 | 
263 |   .dialog-actions {
264 |     gap: 8px;
265 |     flex-direction: column-reverse;
266 |   }
267 | 
268 |   .dialog-button {
269 |     width: 100%;
270 |     padding: 12px 16px;
271 |   }
272 | }
273 | 
274 | /* 焦点样式 */
275 | .dialog-button:focus {
276 |   outline: none;
277 |   box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
278 | }
279 | 
280 | .cancel-button:focus {
281 |   box-shadow: 0 0 0 3px rgba(160, 174, 192, 0.3);
282 | }
283 | 
284 | .confirm-button:focus {
285 |   box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.3);
286 | }
287 | </style>
288 | 
```

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

```typescript
  1 | /**
  2 |  * Action Handlers Registry
  3 |  *
  4 |  * Central registration point for all action handlers.
  5 |  * Provides factory function to create a fully-configured ActionRegistry
  6 |  * with all replay handlers registered.
  7 |  */
  8 | 
  9 | import { ActionRegistry, createActionRegistry } from '../registry';
 10 | import { assertHandler } from './assert';
 11 | import { clickHandler, dblclickHandler } from './click';
 12 | import { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
 13 | import { delayHandler } from './delay';
 14 | import { setAttributeHandler, triggerEventHandler } from './dom';
 15 | import { dragHandler } from './drag';
 16 | import { extractHandler } from './extract';
 17 | import { fillHandler } from './fill';
 18 | import { httpHandler } from './http';
 19 | import { keyHandler } from './key';
 20 | import { navigateHandler } from './navigate';
 21 | import { screenshotHandler } from './screenshot';
 22 | import { scriptHandler } from './script';
 23 | import { scrollHandler } from './scroll';
 24 | import { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';
 25 | import { waitHandler } from './wait';
 26 | 
 27 | // Re-export individual handlers for direct access
 28 | export { assertHandler } from './assert';
 29 | export { clickHandler, dblclickHandler } from './click';
 30 | export { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';
 31 | export { delayHandler } from './delay';
 32 | export { setAttributeHandler, triggerEventHandler } from './dom';
 33 | export { dragHandler } from './drag';
 34 | export { extractHandler } from './extract';
 35 | export { fillHandler } from './fill';
 36 | export { httpHandler } from './http';
 37 | export { keyHandler } from './key';
 38 | export { navigateHandler } from './navigate';
 39 | export { screenshotHandler } from './screenshot';
 40 | export { scriptHandler } from './script';
 41 | export { scrollHandler } from './scroll';
 42 | export { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';
 43 | export { waitHandler } from './wait';
 44 | 
 45 | // Re-export common utilities
 46 | export * from './common';
 47 | 
 48 | /**
 49 |  * All available action handlers for replay
 50 |  *
 51 |  * Organized by category:
 52 |  * - Navigation: navigate
 53 |  * - Interaction: click, dblclick, fill, key, scroll, drag
 54 |  * - Timing: wait, delay
 55 |  * - Validation: assert
 56 |  * - Data: extract, script, http, screenshot
 57 |  * - DOM Tools: triggerEvent, setAttribute
 58 |  * - Tabs: openTab, switchTab, closeTab, handleDownload
 59 |  * - Control Flow: if, foreach, while, switchFrame
 60 |  *
 61 |  * TODO: Add remaining handlers:
 62 |  * - loopElements, executeFlow (advanced control flow)
 63 |  */
 64 | const ALL_HANDLERS = [
 65 |   // Navigation
 66 |   navigateHandler,
 67 |   // Interaction
 68 |   clickHandler,
 69 |   dblclickHandler,
 70 |   fillHandler,
 71 |   keyHandler,
 72 |   scrollHandler,
 73 |   dragHandler,
 74 |   // Timing
 75 |   waitHandler,
 76 |   delayHandler,
 77 |   // Validation
 78 |   assertHandler,
 79 |   // Data
 80 |   extractHandler,
 81 |   scriptHandler,
 82 |   httpHandler,
 83 |   screenshotHandler,
 84 |   // DOM Tools
 85 |   triggerEventHandler,
 86 |   setAttributeHandler,
 87 |   // Tabs
 88 |   openTabHandler,
 89 |   switchTabHandler,
 90 |   closeTabHandler,
 91 |   handleDownloadHandler,
 92 |   // Control Flow
 93 |   ifHandler,
 94 |   foreachHandler,
 95 |   whileHandler,
 96 |   switchFrameHandler,
 97 | ] as const;
 98 | 
 99 | /**
100 |  * Register all replay handlers to an ActionRegistry instance
101 |  */
102 | export function registerReplayHandlers(registry: ActionRegistry): void {
103 |   // Register each handler individually to satisfy TypeScript's type checker
104 |   registry.register(navigateHandler, { override: true });
105 |   registry.register(clickHandler, { override: true });
106 |   registry.register(dblclickHandler, { override: true });
107 |   registry.register(fillHandler, { override: true });
108 |   registry.register(keyHandler, { override: true });
109 |   registry.register(scrollHandler, { override: true });
110 |   registry.register(dragHandler, { override: true });
111 |   registry.register(waitHandler, { override: true });
112 |   registry.register(delayHandler, { override: true });
113 |   registry.register(assertHandler, { override: true });
114 |   registry.register(extractHandler, { override: true });
115 |   registry.register(scriptHandler, { override: true });
116 |   registry.register(httpHandler, { override: true });
117 |   registry.register(screenshotHandler, { override: true });
118 |   registry.register(triggerEventHandler, { override: true });
119 |   registry.register(setAttributeHandler, { override: true });
120 |   registry.register(openTabHandler, { override: true });
121 |   registry.register(switchTabHandler, { override: true });
122 |   registry.register(closeTabHandler, { override: true });
123 |   registry.register(handleDownloadHandler, { override: true });
124 |   registry.register(ifHandler, { override: true });
125 |   registry.register(foreachHandler, { override: true });
126 |   registry.register(whileHandler, { override: true });
127 |   registry.register(switchFrameHandler, { override: true });
128 | }
129 | 
130 | /**
131 |  * Create a new ActionRegistry with all replay handlers registered
132 |  *
133 |  * This is the primary entry point for creating an action execution context.
134 |  *
135 |  * @example
136 |  * ```ts
137 |  * const registry = createReplayActionRegistry();
138 |  *
139 |  * const result = await registry.execute(ctx, {
140 |  *   id: 'action-1',
141 |  *   type: 'click',
142 |  *   params: { target: { candidates: [...] } },
143 |  * });
144 |  * ```
145 |  */
146 | export function createReplayActionRegistry(): ActionRegistry {
147 |   const registry = createActionRegistry();
148 |   registerReplayHandlers(registry);
149 |   return registry;
150 | }
151 | 
152 | /**
153 |  * Get list of supported action types
154 |  */
155 | export function getSupportedActionTypes(): ReadonlyArray<string> {
156 |   return ALL_HANDLERS.map((h) => h.type);
157 | }
158 | 
159 | /**
160 |  * Check if an action type is supported
161 |  */
162 | export function isActionTypeSupported(type: string): boolean {
163 |   return ALL_HANDLERS.some((h) => h.type === type);
164 | }
165 | 
```

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

```vue
  1 | <template>
  2 |   <aside class="property-panel">
  3 |     <div v-if="edge" class="panel-content">
  4 |       <div class="panel-header">
  5 |         <div>
  6 |           <div class="header-title">Edge</div>
  7 |           <div class="header-id">{{ edge.id }}</div>
  8 |         </div>
  9 |         <button class="btn-delete" type="button" title="删除边" @click.stop="onRemove">
 10 |           <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
 11 |             <path
 12 |               d="m4 4 8 8M12 4 4 12"
 13 |               stroke="currentColor"
 14 |               stroke-width="1.8"
 15 |               stroke-linecap="round"
 16 |             />
 17 |           </svg>
 18 |         </button>
 19 |       </div>
 20 | 
 21 |       <div class="form-section">
 22 |         <div class="form-group">
 23 |           <label class="form-label">Source</label>
 24 |           <div class="text">{{ srcName }}</div>
 25 |         </div>
 26 |         <div class="form-group">
 27 |           <label class="form-label">Target</label>
 28 |           <div class="text">{{ dstName }}</div>
 29 |         </div>
 30 |         <div class="form-group">
 31 |           <label class="form-label">Connection status</label>
 32 |           <div class="status" :class="{ ok: isValid, bad: !isValid }">
 33 |             {{ isValid ? 'Valid' : 'Invalid' }}
 34 |           </div>
 35 |         </div>
 36 |         <div class="form-group">
 37 |           <label class="form-label">Branch</label>
 38 |           <div class="text">{{ labelPretty }}</div>
 39 |         </div>
 40 |       </div>
 41 |       <div class="divider"></div>
 42 | 
 43 |       <div class="form-section">
 44 |         <div class="text-xs text-slate-500" style="padding: 0 20px">
 45 |           Inspect connection only. Editing of branch/handles will be supported in a later pass.
 46 |         </div>
 47 |       </div>
 48 |     </div>
 49 |     <div v-else class="panel-empty">
 50 |       <div class="empty-text">未选择边</div>
 51 |     </div>
 52 |   </aside>
 53 | </template>
 54 | 
 55 | <script lang="ts" setup>
 56 | import { computed } from 'vue';
 57 | import type { Edge as EdgeV2, NodeBase } from '@/entrypoints/background/record-replay/types';
 58 | 
 59 | const props = defineProps<{ edge: EdgeV2 | null; nodes: NodeBase[] }>();
 60 | const emit = defineEmits<{ (e: 'remove-edge', id: string): void }>();
 61 | 
 62 | const src = computed(() => props.nodes?.find?.((n) => n.id === (props.edge as any)?.from) || null);
 63 | const dst = computed(() => props.nodes?.find?.((n) => n.id === (props.edge as any)?.to) || null);
 64 | const srcName = computed(() =>
 65 |   src.value ? src.value.name || `${src.value.type} (${src.value.id})` : 'Unknown',
 66 | );
 67 | const dstName = computed(() =>
 68 |   dst.value ? dst.value.name || `${dst.value.type} (${dst.value.id})` : 'Unknown',
 69 | );
 70 | const isValid = computed(() => !!(src.value && dst.value && src.value.id !== dst.value.id));
 71 | const labelPretty = computed(() => {
 72 |   const raw = String((props.edge as any)?.label || 'default');
 73 |   if (raw === 'default') return 'default';
 74 |   if (raw === 'true') return 'true ✓';
 75 |   if (raw === 'false') return 'false ✗';
 76 |   if (raw === 'onError') return 'onError !';
 77 |   if (raw === 'else') return 'else';
 78 |   if (raw.startsWith('case:')) {
 79 |     const id = raw.slice('case:'.length);
 80 |     const ifNode = src.value && (src.value as any).type === 'if' ? (src.value as any) : null;
 81 |     const found = ifNode?.config?.branches?.find?.((b: any) => String(b.id) === id);
 82 |     if (found) return `case: ${found.name || found.expr || id}`;
 83 |     return `case: ${id}`;
 84 |   }
 85 |   return raw;
 86 | });
 87 | 
 88 | function onRemove() {
 89 |   if (!props.edge) return;
 90 |   emit('remove-edge', props.edge.id);
 91 | }
 92 | </script>
 93 | 
 94 | <style scoped>
 95 | .property-panel {
 96 |   background: var(--rr-card);
 97 |   border: 1px solid var(--rr-border);
 98 |   border-radius: 16px;
 99 |   margin: 16px;
100 |   padding: 0;
101 |   width: 380px;
102 |   display: flex;
103 |   flex-direction: column;
104 |   max-height: calc(100vh - 72px);
105 |   overflow-y: auto;
106 |   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
107 |   flex-shrink: 0;
108 |   scrollbar-width: none;
109 |   scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
110 | }
111 | .panel-content {
112 |   display: flex;
113 |   flex-direction: column;
114 | }
115 | .panel-header {
116 |   padding: 12px 12px 12px 20px;
117 |   border-bottom: 1px solid var(--rr-border);
118 |   display: flex;
119 |   align-items: center;
120 |   justify-content: space-between;
121 |   gap: 8px;
122 | }
123 | .header-title {
124 |   font-size: 15px;
125 |   font-weight: 600;
126 |   color: var(--rr-text);
127 |   margin-bottom: 4px;
128 | }
129 | .header-id {
130 |   font-size: 11px;
131 |   color: var(--rr-text-weak);
132 |   font-family: 'Monaco', monospace;
133 |   opacity: 0.7;
134 | }
135 | .btn-delete {
136 |   width: 28px;
137 |   height: 28px;
138 |   display: flex;
139 |   align-items: center;
140 |   justify-content: center;
141 |   border: 1px solid var(--rr-border);
142 |   background: var(--rr-card);
143 |   color: var(--rr-danger);
144 |   border-radius: 6px;
145 |   cursor: pointer;
146 | }
147 | .btn-delete:hover {
148 |   background: rgba(239, 68, 68, 0.08);
149 |   border-color: rgba(239, 68, 68, 0.3);
150 | }
151 | .form-section {
152 |   padding: 16px 20px;
153 |   display: flex;
154 |   flex-direction: column;
155 |   gap: 14px;
156 | }
157 | .form-group {
158 |   display: grid;
159 |   grid-template-columns: 110px 1fr;
160 |   align-items: center;
161 |   gap: 8px;
162 | }
163 | .form-label {
164 |   color: var(--rr-text-secondary);
165 |   font-size: 13px;
166 |   font-weight: 500;
167 | }
168 | .text {
169 |   font-size: 13px;
170 | }
171 | .status.ok {
172 |   color: #059669;
173 |   font-weight: 600;
174 | }
175 | .status.bad {
176 |   color: #ef4444;
177 |   font-weight: 600;
178 | }
179 | .divider {
180 |   height: 1px;
181 |   background: var(--rr-border);
182 | }
183 | .panel-empty {
184 |   display: flex;
185 |   align-items: center;
186 |   justify-content: center;
187 |   padding: 40px 20px;
188 | }
189 | .empty-text {
190 |   color: var(--rr-text-secondary);
191 | }
192 | 
193 | /* Hide scrollbars in WebKit while keeping scrollability */
194 | .property-panel :deep(::-webkit-scrollbar) {
195 |   width: 0;
196 |   height: 0;
197 | }
198 | .property-panel :deep(::-webkit-scrollbar-thumb) {
199 |   background-color: rgba(0, 0, 0, 0.25);
200 |   border-radius: 6px;
201 | }
202 | .property-panel :deep(::-webkit-scrollbar-track) {
203 |   background: transparent !important;
204 | }
205 | </style>
206 | 
```
Page 5/60FirstPrevNextLast