Repository: chrysb/alphaclaw Branch: main Commit: 27a548e988e6 Files: 480 Total size: 3.1 MB Directory structure: gitextract_dikz81a3/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .npmrc ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin/ │ └── alphaclaw.js ├── lib/ │ ├── cli/ │ │ ├── git-runtime.js │ │ ├── git-sync.js │ │ └── openclaw-config-restore.js │ ├── plugin/ │ │ └── usage-tracker/ │ │ ├── index.js │ │ └── openclaw.plugin.json │ ├── public/ │ │ ├── css/ │ │ │ ├── agents.css │ │ │ ├── chat.css │ │ │ ├── cron.css │ │ │ ├── explorer.css │ │ │ ├── shell.css │ │ │ ├── tailwind.input.css │ │ │ └── theme.css │ │ ├── js/ │ │ │ ├── app.js │ │ │ ├── components/ │ │ │ │ ├── action-button.js │ │ │ │ ├── add-channel-menu.js │ │ │ │ ├── agent-send-modal.js │ │ │ │ ├── agents-tab/ │ │ │ │ │ ├── agent-bindings-section/ │ │ │ │ │ │ ├── channel-item-trailing.js │ │ │ │ │ │ ├── helpers.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── use-agent-bindings.js │ │ │ │ │ │ └── use-channel-items.js │ │ │ │ │ ├── agent-detail-panel.js │ │ │ │ │ ├── agent-identity-section.js │ │ │ │ │ ├── agent-overview/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── manage-card.js │ │ │ │ │ │ ├── model-card.js │ │ │ │ │ │ ├── tools-card.js │ │ │ │ │ │ ├── use-model-card.js │ │ │ │ │ │ ├── use-workspace-card.js │ │ │ │ │ │ └── workspace-card.js │ │ │ │ │ ├── agent-pairing-section.js │ │ │ │ │ ├── agent-tools/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── tool-catalog.js │ │ │ │ │ │ └── use-agent-tools.js │ │ │ │ │ ├── create-agent-modal.js │ │ │ │ │ ├── create-channel-modal.js │ │ │ │ │ ├── delete-agent-dialog.js │ │ │ │ │ ├── edit-agent-modal.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── use-agents.js │ │ │ │ ├── badge.js │ │ │ │ ├── channel-account-status-badge.js │ │ │ │ ├── channel-login-modal.js │ │ │ │ ├── channel-operations-panel.js │ │ │ │ ├── channels.js │ │ │ │ ├── confirm-dialog.js │ │ │ │ ├── credentials-modal.js │ │ │ │ ├── cron-tab/ │ │ │ │ │ ├── cron-calendar-helpers.js │ │ │ │ │ ├── cron-calendar.js │ │ │ │ │ ├── cron-helpers.js │ │ │ │ │ ├── cron-insights-panel.js │ │ │ │ │ ├── cron-job-detail.js │ │ │ │ │ ├── cron-job-list.js │ │ │ │ │ ├── cron-job-settings-card.js │ │ │ │ │ ├── cron-job-trends-panel.js │ │ │ │ │ ├── cron-job-usage.js │ │ │ │ │ ├── cron-overview.js │ │ │ │ │ ├── cron-prompt-editor.js │ │ │ │ │ ├── cron-run-history-panel.js │ │ │ │ │ ├── cron-runs-trend-card.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── use-cron-tab.js │ │ │ │ ├── device-pairings.js │ │ │ │ ├── doctor/ │ │ │ │ │ ├── findings-list.js │ │ │ │ │ ├── fix-card-modal.js │ │ │ │ │ ├── general-warning.js │ │ │ │ │ ├── helpers.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── summary-cards.js │ │ │ │ ├── envars.js │ │ │ │ ├── features.js │ │ │ │ ├── file-tree.js │ │ │ │ ├── file-viewer/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── diff-viewer.js │ │ │ │ │ ├── editor-surface.js │ │ │ │ │ ├── frontmatter-panel.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── markdown-split-view.js │ │ │ │ │ ├── media-preview.js │ │ │ │ │ ├── scroll-sync.js │ │ │ │ │ ├── sqlite-viewer.js │ │ │ │ │ ├── status-banners.js │ │ │ │ │ ├── storage.js │ │ │ │ │ ├── toolbar.js │ │ │ │ │ ├── use-editor-line-number-sync.js │ │ │ │ │ ├── use-editor-selection-restore.js │ │ │ │ │ ├── use-file-diff.js │ │ │ │ │ ├── use-file-loader.js │ │ │ │ │ ├── use-file-viewer-draft-sync.js │ │ │ │ │ ├── use-file-viewer-hotkeys.js │ │ │ │ │ ├── use-file-viewer.js │ │ │ │ │ └── utils.js │ │ │ │ ├── gateway.js │ │ │ │ ├── general/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── use-general-tab.js │ │ │ │ ├── global-restart-banner.js │ │ │ │ ├── google/ │ │ │ │ │ ├── account-row.js │ │ │ │ │ ├── add-account-modal.js │ │ │ │ │ ├── gmail-setup-wizard.js │ │ │ │ │ ├── gmail-watch-toggle.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── use-gmail-watch.js │ │ │ │ │ └── use-google-accounts.js │ │ │ │ ├── icons.js │ │ │ │ ├── info-tooltip.js │ │ │ │ ├── loading-spinner.js │ │ │ │ ├── modal-shell.js │ │ │ │ ├── models-tab/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── model-picker.js │ │ │ │ │ ├── provider-auth-card.js │ │ │ │ │ └── use-models.js │ │ │ │ ├── models.js │ │ │ │ ├── nodes-tab/ │ │ │ │ │ ├── browser-attach/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── connected-nodes/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── use-connected-nodes-card.js │ │ │ │ │ │ └── user-connected-nodes.js │ │ │ │ │ ├── exec-allowlist/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-exec-allowlist.js │ │ │ │ │ ├── exec-config/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-exec-config.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── setup-wizard/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-setup-wizard.js │ │ │ │ │ └── use-nodes-tab.js │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── pairing-utils.js │ │ │ │ │ ├── use-welcome-codex.js │ │ │ │ │ ├── use-welcome-pairing.js │ │ │ │ │ ├── use-welcome-storage.js │ │ │ │ │ ├── welcome-config.js │ │ │ │ │ ├── welcome-form-step.js │ │ │ │ │ ├── welcome-header.js │ │ │ │ │ ├── welcome-import-step.js │ │ │ │ │ ├── welcome-pairing-step.js │ │ │ │ │ ├── welcome-placeholder-review-step.js │ │ │ │ │ ├── welcome-pre-step.js │ │ │ │ │ ├── welcome-secret-review-step.js │ │ │ │ │ ├── welcome-secret-review-utils.js │ │ │ │ │ └── welcome-setup-step.js │ │ │ │ ├── overflow-menu.js │ │ │ │ ├── page-header.js │ │ │ │ ├── pairings.js │ │ │ │ ├── pane-shell.js │ │ │ │ ├── pill-tabs.js │ │ │ │ ├── pop-actions.js │ │ │ │ ├── providers.js │ │ │ │ ├── routes/ │ │ │ │ │ ├── agents-route.js │ │ │ │ │ ├── browse-route.js │ │ │ │ │ ├── chat-route.js │ │ │ │ │ ├── cron-route.js │ │ │ │ │ ├── doctor-route.js │ │ │ │ │ ├── envars-route.js │ │ │ │ │ ├── general-route.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── models-route.js │ │ │ │ │ ├── nodes-route.js │ │ │ │ │ ├── providers-route.js │ │ │ │ │ ├── route-redirect.js │ │ │ │ │ ├── telegram-route.js │ │ │ │ │ ├── usage-route.js │ │ │ │ │ ├── watchdog-route.js │ │ │ │ │ └── webhooks-route.js │ │ │ │ ├── scope-picker.js │ │ │ │ ├── secret-input.js │ │ │ │ ├── segmented-control.js │ │ │ │ ├── session-select-field.js │ │ │ │ ├── sidebar-git-panel.js │ │ │ │ ├── sidebar.js │ │ │ │ ├── summary-stat-card.js │ │ │ │ ├── telegram-workspace/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── manage.js │ │ │ │ │ └── onboarding.js │ │ │ │ ├── theme-toggle.js │ │ │ │ ├── toast.js │ │ │ │ ├── toggle-switch.js │ │ │ │ ├── tooltip.js │ │ │ │ ├── update-action-button.js │ │ │ │ ├── update-modal.js │ │ │ │ ├── usage-tab/ │ │ │ │ │ ├── constants.js │ │ │ │ │ ├── formatters.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── overview-section.js │ │ │ │ │ ├── sessions-section.js │ │ │ │ │ └── use-usage-tab.js │ │ │ │ ├── watchdog-tab/ │ │ │ │ │ ├── console/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-console.js │ │ │ │ │ ├── helpers.js │ │ │ │ │ ├── incidents/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-incidents.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── resource-bar.js │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-resources.js │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-settings.js │ │ │ │ │ ├── terminal/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-terminal.js │ │ │ │ │ └── use-watchdog-tab.js │ │ │ │ ├── webhooks/ │ │ │ │ │ ├── create-webhook-modal/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── helpers.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── request-history/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-request-history.js │ │ │ │ │ ├── webhook-detail/ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── use-webhook-detail.js │ │ │ │ │ └── webhook-list/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── use-webhook-list.js │ │ │ │ └── welcome/ │ │ │ │ ├── index.js │ │ │ │ └── use-welcome.js │ │ │ ├── hooks/ │ │ │ │ ├── use-app-shell-controller.js │ │ │ │ ├── use-app-shell-ui.js │ │ │ │ ├── use-browse-navigation.js │ │ │ │ ├── use-cached-fetch.js │ │ │ │ ├── use-destination-session-selection.js │ │ │ │ ├── use-hash-location.js │ │ │ │ ├── useAgentSessions.js │ │ │ │ └── usePolling.js │ │ │ ├── lib/ │ │ │ │ ├── agent-identity.js │ │ │ │ ├── api-cache.js │ │ │ │ ├── api.js │ │ │ │ ├── app-navigation.js │ │ │ │ ├── browse-draft-state.js │ │ │ │ ├── browse-file-policies.js │ │ │ │ ├── browse-restart-policy.js │ │ │ │ ├── browse-route.js │ │ │ │ ├── channel-accounts.js │ │ │ │ ├── channel-create-operation.js │ │ │ │ ├── channel-provider-availability.js │ │ │ │ ├── clipboard.js │ │ │ │ ├── codex-oauth-window.js │ │ │ │ ├── file-highlighting.js │ │ │ │ ├── file-tree-utils.js │ │ │ │ ├── format.js │ │ │ │ ├── model-catalog.js │ │ │ │ ├── model-config.js │ │ │ │ ├── session-keys.js │ │ │ │ ├── sse.js │ │ │ │ ├── storage-keys.js │ │ │ │ ├── syntax-highlighters/ │ │ │ │ │ ├── css.js │ │ │ │ │ ├── frontmatter.js │ │ │ │ │ ├── html.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── javascript.js │ │ │ │ │ ├── json.js │ │ │ │ │ ├── markdown.js │ │ │ │ │ └── utils.js │ │ │ │ ├── telegram-api.js │ │ │ │ └── ui-settings.js │ │ │ └── tailwind-config.js │ │ ├── login.html │ │ ├── setup.html │ │ └── shared/ │ │ └── browse-file-policies.json │ ├── scripts/ │ │ ├── git │ │ ├── git-askpass │ │ └── systemctl │ ├── server/ │ │ ├── agents/ │ │ │ ├── agents.js │ │ │ ├── bindings.js │ │ │ ├── channels.js │ │ │ ├── service.js │ │ │ └── shared.js │ │ ├── alphaclaw-version.js │ │ ├── auth-profiles.js │ │ ├── chat-ws.js │ │ ├── commands.js │ │ ├── constants.js │ │ ├── cost-utils.js │ │ ├── cron-service.js │ │ ├── db/ │ │ │ ├── doctor/ │ │ │ │ ├── index.js │ │ │ │ └── schema.js │ │ │ ├── usage/ │ │ │ │ ├── index.js │ │ │ │ ├── pricing.js │ │ │ │ ├── schema.js │ │ │ │ ├── sessions.js │ │ │ │ ├── shared.js │ │ │ │ ├── summary.js │ │ │ │ └── timeseries.js │ │ │ ├── watchdog/ │ │ │ │ ├── index.js │ │ │ │ └── schema.js │ │ │ └── webhooks/ │ │ │ ├── index.js │ │ │ └── schema.js │ │ ├── discord-api.js │ │ ├── doctor/ │ │ │ ├── bootstrap-context.js │ │ │ ├── constants.js │ │ │ ├── normalize.js │ │ │ ├── prompt.js │ │ │ ├── service.js │ │ │ └── workspace-fingerprint.js │ │ ├── env.js │ │ ├── exec-defaults-config.js │ │ ├── gateway.js │ │ ├── gmail-push.js │ │ ├── gmail-serve.js │ │ ├── gmail-watch.js │ │ ├── gog-skill.js │ │ ├── google-state.js │ │ ├── helpers.js │ │ ├── init/ │ │ │ ├── register-server-routes.js │ │ │ ├── runtime-init.js │ │ │ └── server-lifecycle.js │ │ ├── internal-files-migration.js │ │ ├── log-writer.js │ │ ├── login-throttle.js │ │ ├── model-catalog-bootstrap.json │ │ ├── model-catalog-cache.js │ │ ├── oauth-callback-middleware.js │ │ ├── onboarding/ │ │ │ ├── cron.js │ │ │ ├── github.js │ │ │ ├── import/ │ │ │ │ ├── import-applier.js │ │ │ │ ├── import-config.js │ │ │ │ ├── import-scanner.js │ │ │ │ ├── import-temp.js │ │ │ │ └── secret-detector.js │ │ │ ├── index.js │ │ │ ├── openclaw.js │ │ │ ├── validation.js │ │ │ └── workspace.js │ │ ├── openclaw-config.js │ │ ├── openclaw-runtime-env.js │ │ ├── openclaw-version.js │ │ ├── operation-events.js │ │ ├── restart-required-state.js │ │ ├── routes/ │ │ │ ├── agents.js │ │ │ ├── auth.js │ │ │ ├── browse/ │ │ │ │ ├── constants.js │ │ │ │ ├── file-helpers.js │ │ │ │ ├── git.js │ │ │ │ ├── index.js │ │ │ │ ├── path-utils.js │ │ │ │ └── sqlite.js │ │ │ ├── codex.js │ │ │ ├── cron.js │ │ │ ├── doctor.js │ │ │ ├── gmail.js │ │ │ ├── google.js │ │ │ ├── models.js │ │ │ ├── nodes.js │ │ │ ├── onboarding.js │ │ │ ├── pages.js │ │ │ ├── pairings.js │ │ │ ├── proxy.js │ │ │ ├── system.js │ │ │ ├── telegram.js │ │ │ ├── usage.js │ │ │ ├── watchdog.js │ │ │ └── webhooks.js │ │ ├── slack-api.js │ │ ├── startup.js │ │ ├── system-resources.js │ │ ├── telegram-api.js │ │ ├── telegram-workspace.js │ │ ├── topic-registry.js │ │ ├── usage-tracker-config.js │ │ ├── utils/ │ │ │ ├── boolean.js │ │ │ ├── channels.js │ │ │ ├── command-output.js │ │ │ ├── json.js │ │ │ ├── network.js │ │ │ ├── number.js │ │ │ └── shell.js │ │ ├── watchdog-notify.js │ │ ├── watchdog-terminal-ws.js │ │ ├── watchdog-terminal.js │ │ ├── watchdog.js │ │ ├── webhook-middleware.js │ │ └── webhooks.js │ ├── server.js │ └── setup/ │ ├── core-prompts/ │ │ ├── AGENTS.md │ │ └── TOOLS.md │ ├── env.template │ ├── gitignore │ ├── hourly-git-sync.sh │ └── skills/ │ └── gog-cli/ │ ├── calendar.md │ ├── contacts.md │ ├── docs.md │ ├── drive.md │ ├── gmail.md │ ├── meet.md │ ├── sheets.md │ └── tasks.md ├── package.json ├── scripts/ │ ├── build-ui.mjs │ └── dev/ │ └── crash-watchdog-config.sh ├── tailwind.config.cjs ├── tests/ │ ├── bin/ │ │ ├── alphaclaw.test.js │ │ └── openclaw-config-restore.test.js │ ├── frontend/ │ │ ├── agent-identity.test.js │ │ ├── api.test.js │ │ ├── browse-draft-state.test.js │ │ ├── channel-create-operation.test.js │ │ ├── channel-provider-availability.test.js │ │ ├── clipboard.test.js │ │ ├── codex-oauth-window.test.js │ │ ├── cron-calendar-helpers.test.js │ │ ├── cron-helpers.test.js │ │ ├── doctor-helpers.test.js │ │ ├── file-tree-utils.test.js │ │ ├── file-viewer-utils.test.js │ │ ├── model-catalog.test.js │ │ ├── model-config.test.js │ │ ├── pairing-utils.test.js │ │ ├── session-keys.test.js │ │ ├── syntax-highlighters.test.js │ │ ├── watchdog-helpers.test.js │ │ ├── welcome-config.test.js │ │ └── welcome-secret-review-utils.test.js │ └── server/ │ ├── agents-service.test.js │ ├── alphaclaw-version.test.js │ ├── auth-profiles.test.js │ ├── chat-ws.test.js │ ├── commands.test.js │ ├── cost-utils.test.js │ ├── cron-service.test.js │ ├── doctor-db.test.js │ ├── doctor-normalize.test.js │ ├── doctor-prompt.test.js │ ├── doctor-service.test.js │ ├── exec-defaults-config.test.js │ ├── express-runtime-guard.test.js │ ├── gateway.test.js │ ├── git-runtime.test.js │ ├── git-shim.test.js │ ├── git-sync-path.test.js │ ├── gmail-push.test.js │ ├── gmail-watch.test.js │ ├── gog-skill.test.js │ ├── helpers.test.js │ ├── import-applier.test.js │ ├── import-scanner.test.js │ ├── import-temp.test.js │ ├── internal-files-migration.test.js │ ├── login-throttle.test.js │ ├── model-catalog-cache.test.js │ ├── oauth-callback-middleware.test.js │ ├── onboarding-github.test.js │ ├── onboarding-openclaw.test.js │ ├── onboarding-validation.test.js │ ├── onboarding-workspace.test.js │ ├── openclaw-runtime-env.test.js │ ├── openclaw-version.test.js │ ├── operation-events.test.js │ ├── routes-agents.test.js │ ├── routes-auth.test.js │ ├── routes-browse.test.js │ ├── routes-cron.test.js │ ├── routes-doctor.test.js │ ├── routes-models.test.js │ ├── routes-nodes.test.js │ ├── routes-onboarding.test.js │ ├── routes-pairings.test.js │ ├── routes-system.test.js │ ├── routes-telegram.test.js │ ├── routes-usage.test.js │ ├── routes-watchdog-test-notification.test.js │ ├── routes-watchdog.test.js │ ├── routes-webhooks.test.js │ ├── secret-detector.test.js │ ├── slack-api.test.js │ ├── startup.test.js │ ├── telegram-workspace.test.js │ ├── topic-registry.test.js │ ├── usage-db.test.js │ ├── usage-tracker-config.test.js │ ├── utils-boolean.test.js │ ├── utils-json.test.js │ ├── utils-shell.test.js │ ├── watchdog-db.test.js │ ├── watchdog-notify.test.js │ ├── watchdog.test.js │ ├── webhook-middleware.test.js │ ├── webhooks-db.test.js │ └── webhooks.test.js └── vitest.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: chrysb ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm run build:ui - run: npm test ================================================ FILE: .gitignore ================================================ node_modules/ .DS_Store .env .cursor/ coverage/ workspace/.openclaw/ # Build artifacts (generated by npm run build:ui) lib/public/dist/ lib/public/css/tailwind.generated.css lib/public/css/vendor/ ================================================ FILE: .npmrc ================================================ @chrysb:registry=https://registry.npmjs.org/ ================================================ FILE: AGENTS.md ================================================ ## Project Overview ### AlphaClaw Project Context AlphaClaw is the ops and setup layer around OpenClaw. It provides a browser-based setup UI, gateway lifecycle management, watchdog recovery flows, and integrations (for example Telegram, Discord, Google Workspace, and webhooks) so users can operate OpenClaw without manual server intervention. ### Understanding OpenClaw If you need to understand the internals of OpenClaw, you can inspect the code at `~/Projects/openclaw/src` ### Architecture At A Glance - `bin/alphaclaw.js`: CLI entrypoint and lifecycle command surface. - `lib/server`: Express server, authenticated setup APIs, watchdog APIs, channel integrations, and proxying to the OpenClaw gateway. - `lib/public`: Setup UI frontend (component-driven tabs and flows for providers, envars, watchdog, webhooks, and onboarding). - `lib/setup`: Prompt hardening templates and setup-related assets injected into agent/system behavior. Runtime model: 1. AlphaClaw server starts and manages OpenClaw as a child process. 2. Setup UI calls AlphaClaw APIs for configuration and operations. 3. AlphaClaw proxies gateway traffic and handles watchdog monitoring/repair. ### Key Technologies - Node.js 22.14+ runtime. - Express-based HTTP API server. - `http-proxy` for gateway proxy behavior. - OpenClaw CLI/gateway process orchestration. - Preact + `htm` frontend patterns for Setup UI components. - Vitest + Supertest for server and route testing. ## Coding Conventions ### Change Patterns - Keep edits targeted and production-safe; favor small, reviewable changes. - Preserve existing behavior unless the task explicitly requires behavior changes. - Follow existing UI conventions and shared components for consistency. - Reuse existing server route and state patterns before introducing new abstractions. - Update tests when behavior changes in routes, watchdog flows, or setup state. - Before running tests in a fresh checkout, run `npm install` so `vitest` (devDependency) is available for `npm test`. ### Code Structure - Avoid monolithic implementation files for new features. For new UI areas and new API areas, start with a decomposed structure (focused components/hooks/utilities for UI; focused route modules/services/helpers for server) rather than building one large file first and splitting later. - When adding a new feature area, follow the existing project patterns from day one (for example feature folders with `index.js` plus `use-*` hooks in UI, and route + service separation on server) so code stays maintainable as the feature grows. - When continuing to build on a file that is growing large or accumulating unrelated concerns, stop and decompose it before adding more code rather than letting it drift into a monolith. ### Networking and Fetching - Prefer the shared cache primitives in `lib/public/js/lib/api-cache.js` for backend reads: - `cachedFetch(...)` for imperative fetch paths. - `getCached(...)` / `setCached(...)` / `invalidateCache(...)` for cache lifecycle. - For component-level read requests, prefer `useCachedFetch` from `lib/public/js/hooks/use-cached-fetch.js` over ad-hoc `useEffect(() => fetchX())` mount loads. - Treat the API URL (including query params) as the canonical cache key for GET-style payloads. - Keep cache in-memory for fast tab switches; do not add persistent storage caching unless explicitly required by product behavior. - Do not keep route panes mounted via `display:none` just to preserve data. Prefer conditional rendering + cache-backed remounts. - Use `usePolling` for recurring refreshes and always pass a stable `cacheKey` when poll results should hydrate remounts. - Keep `pauseWhenHidden` behavior enabled for polling unless a specific flow requires background polling while the browser tab is hidden. - Tune polling intervals conservatively; avoid 1-2s polling unless there is a clear real-time requirement. - For app-shell status streams, prefer SSE (`/api/events/status`) where available and keep polling as fallback behavior. - After write/mutation APIs (POST/PUT/DELETE), refresh or invalidate relevant cached keys so the UI does not show stale data. ### OpenClaw Config Access - When reading `openclaw.json` in server code, use the shared helper in `lib/server/openclaw-config.js` (`readOpenclawConfig`) instead of ad-hoc `JSON.parse(fs.readFileSync(...))` blocks. ### Where To Put Agent Guidance - **This file (`AGENTS.md`):** Project-level guidance for coding agents working on the AlphaClaw codebase — architecture, conventions, release flow, UI patterns, etc. - **`lib/setup/core-prompts/AGENTS.md`:** Runtime prompt injected into the OpenClaw agent's system prompt. Only write there when the guidance is meant for the deployed agent's behavior, not for coding on this project. ## Operations ### Release Flow (Beta -> Production) Use this release flow when promoting tested beta builds to production: 1. Ensure `main` is clean and synced, and tests pass. 2. Publish beta iterations as needed: - `npm version prerelease --preid=beta` - `git push && git push --tags` - `npm publish --tag beta` 3. Immediately after each beta publish, update `~/Projects/openclaw-railway-template` on the `beta` branch to pin the exact beta version in `package.json` (for example `0.3.2-beta.4`), then commit and push that template change. Do not leave the beta template on `latest`, or Docker layer cache can reuse an older install. 4. When ready for production, publish a stable release version (for example `0.3.2`): - `npm version 0.3.2` - `git push && git push --tags` - `npm publish` (publishes to `latest`) - Pin all deployment templates on `main` to that release: set `@chrysb/alphaclaw` in `~/Projects/openclaw-railway-template`, `~/Projects/openclaw-render-template`, and `~/Projects/openclaw-apex-template` to the released version (templates rely on AlphaClaw’s declared `openclaw` dependency — do not add `package.json` `overrides` for `openclaw` unless you have a one-off debug reason). Run `npm install` in each repo, confirm `npm ls openclaw` matches AlphaClaw’s `package.json` pin, commit `package.json` and `package-lock.json`, and push. Skipping a template leaves it stale relative to the others. 5. Return templates to production channel: - `@chrysb/alphaclaw: "latest"` 6. Optionally keep beta branch/tag flows active for next release cycle. ### Runtime Dependency Guardrails (Express 4 vs 5) AlphaClaw currently expects Express 4 semantics in its setup API layer. A broken container dependency tree can accidentally resolve `express@5` at `/app/node_modules/express`, which causes subtle request handling regressions (for example body parsing behavior on certain methods). Known root cause pattern: - Mutating `/app/node_modules` in-place (for example copy-over installs used for emergency package swaps) can leave the runtime tree inconsistent with `/app/package.json`. - This can hoist `express@5` to the app root, so `require("express")` inside AlphaClaw resolves the wrong major version. Preferred fix/recovery: 1. Ensure template `package.json` pins the intended `@chrysb/alphaclaw` version. 2. Rebuild the `openclaw` container from scratch (no cache) and recreate it: - `docker compose down` - `docker compose build --no-cache openclaw` - `docker compose up -d openclaw` 3. Verify runtime resolution inside the container: - `node -p "require('express/package.json').version"` should be `4.x` - `npm ls express` should show `@chrysb/alphaclaw` on `express@4.x` (OpenClaw can still carry its own `express@5` subtree). ### Telegram Notice Format (AlphaClaw) Use this format for any Telegram notices sent from AlphaClaw services (watchdog, system alerts, repair notices): 1. Header line (Markdown): `🐺 *AlphaClaw Watchdog*` 2. Headline line (simple, no `Status:` prefix): - `🔴 Crash loop detected` - `🔴 Crash loop detected, auto-repairing...` - `🟡 Auto-repair started, awaiting health check` - `🟢 Auto-repair complete, gateway healthy` - `🟢 Gateway healthy again` - `🔴 Auto-repair failed` 3. Append a markdown link to the headline when URL is available: - `... - [View logs](/#/watchdog)` 4. Optional context lines like `Trigger: ...`, `Attempt count: ...` 5. For values with underscores or special characters (for example `crash_loop`), wrap the value in backticks: - `Trigger: \`crash_loop\`` 6. Do not use HTML tags (``, ``) for Telegram watchdog notices. ## UI Conventions Use these conventions for all UI work under `lib/public/js` and `lib/public/css`. ### Setup UI bundle (esbuild) - The browser loads the compiled bundle under `lib/public/dist/` (for example `app.bundle.js` and chunk files), produced by `scripts/build-ui.mjs` (esbuild). - **After any UI source change** that should ship in production (`lib/public/js`, `lib/public/css`, or other inputs to the build), run **`npm run build:ui`** so `lib/public/dist/` stays in sync. Verify the app in the browser against the rebuilt bundle when the change is non-trivial. - **`npm publish`** runs **`prepack`** → **`npm run build:ui`**, so published packages always include a fresh bundle. Local installs, Docker builds from a git checkout, or commits that include `dist/` still require **`npm run build:ui`** when you change UI sources and expect the built assets to match. ### Component structure - Use arrow-function components and helpers. - Prefer shared components over one-off markup when a pattern already exists. - Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`). - Keep component-level helpers near the top of the file, before the main export. - Treat `index.js` as a presentational shell whenever possible: keep business logic in hooks and pass derived state/actions down as props. - Add reusable SVG icons to `lib/public/js/components/icons.js` and import them from there; avoid introducing one-off inline SVGs in feature files when a shared icon component can be used. ### Rendering and composition - Use the `htm` + `preact` pattern: - `const html = htm.bind(h);` - return `html\`...\`` - In `htm` templates, be explicit with inline spacing around styled inline tags (``, ``, ``): use ` ${" "}` where needed, and verify rendered copy so words never collapse (`eventsand`) or gain double spaces. - Prefer early return for hidden states (e.g. `if (!visible) return null;`). - Use `` for tab/page headers that need a title and right-side actions. - Use card shells consistently: `bg-surface border border-border rounded-xl`. - For nested "surface on surface" blocks (content inside a `bg-surface` card), use `ac-surface-inset` for the inner container treatment so inset sections match shared history/sessions styling. - For internal section dividers, use `border-t border-border` (avoid opacity variants) with comfortable vertical spacing around the divider. ### Color and theme tokens - Prefer semantic Tailwind color utilities backed by theme tokens (`text-body`, `text-fg-muted`, `text-fg-dim`, `bg-field`, `bg-status-error-bg`, `border-status-warning-border`) instead of raw palette classes like `text-gray-300` or `bg-red-900/30`. - When a new reusable UI color role is needed, add the CSS variable in `lib/public/css/theme.css` and expose it through `tailwind.config.cjs` rather than introducing one-off hardcoded color classes in components. - Keep component refactors token-based so future theme changes stay centralized in the token layer instead of requiring per-component color rewrites. ### Buttons - Primary actions: `ac-btn-cyan` - Secondary actions: `ac-btn-secondary` - Positive/success actions: `ac-btn-green` - Ghost/text actions: `ac-btn-ghost` (use for low-emphasis actions like "Disconnect" or "Add provider") - Destructive inline actions: `ac-btn-danger` - Use consistent disabled treatment: `opacity-50 cursor-not-allowed`. - Keep action sizing consistent (`text-xs px-3 py-1.5 rounded-lg` for compact controls unless there is a clear reason otherwise). - For `` actions, use `ac-btn-cyan` (primary) or `ac-btn-secondary` (secondary) by default; avoid ghost/text-only styling for main header actions. - Prefer shared action components when available (`ActionButton`, `UpdateActionButton`, `ConfirmDialog`) before custom button logic. - In setup/onboarding auth flows (e.g. Codex OAuth), prefer `` over raw ` alphaclaw ${browseState.isBrowseRoute ? html`
<${BrowseRoute} activeBrowsePath=${browseState.activeBrowsePath} browseView=${browseState.browseViewerMode} lineTarget=${browseState.browseLineTarget} lineEndTarget=${browseState.browseLineEndTarget} selectedBrowsePath=${browseState.selectedBrowsePath} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} onEditSelectedBrowseFile=${() => setLocation( browseActions.buildBrowseRoute(browseState.selectedBrowsePath, { view: "edit", }), )} onClearSelection=${() => { browseActions.clearBrowsePreview(); setLocation("/browse"); }} />
` : null} ${isAgentsRoute ? html`
<${AgentsRoute} agents=${agentsState.agents} loading=${agentsState.loading} saving=${agentsState.saving} agentsActions=${agentsActions} selectedAgentId=${selectedAgentId} activeTab=${agentDetailTab} onSelectAgent=${(agentId) => setLocation(`/agents/${encodeURIComponent(agentId)}`)} onSelectTab=${(tab) => { const safePath = tab && tab !== "overview" ? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}` : `/agents/${encodeURIComponent(selectedAgentId)}`; setLocation(safePath); }} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} onSetLocation=${setLocation} />
` : null} ${isChatRoute ? html`
<${ChatRoute} sessions=${chatSessions} selectedSessionKey=${selectedChatSessionKey} />
` : null} ${isCronRoute ? html`
<${CronRoute} jobId=${selectedCronJobId} onSetLocation=${setLocation} />
` : null} ${isEnvarsRoute ? html`
<${EnvarsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
` : null} ${isModelsRoute ? html`
<${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
` : null} ${isNodesRoute ? html`
<${NodesRoute} onRestartRequired=${controllerActions.setRestartRequired} />
` : null} ${browseState.isBrowseRoute || isAgentsRoute || isChatRoute || isCronRoute || isEnvarsRoute || isModelsRoute || isNodesRoute ? null : html`
<${Switch}> <${Route} path="/general"> <${GeneralRoute} statusData=${controllerState.sharedStatus} watchdogData=${controllerState.sharedWatchdogStatus} doctorStatusData=${controllerState.sharedDoctorStatus} agents=${agentsState.agents} doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs} onRefreshStatuses=${controllerActions.refreshSharedStatuses} onSetLocation=${setLocation} onNavigate=${browseActions.navigateToSubScreen} restartingGateway=${controllerState.restartingGateway} onRestartGateway=${controllerActions.handleGatewayRestart} restartSignal=${controllerState.gatewayRestartSignal} onRestartRequired=${controllerActions.setRestartRequired} onDismissDoctorWarning=${() => setDoctorWarningDismissedUntilMs( Date.now() + kOneWeekMs, )} /> <${Route} path="/doctor"> <${DoctorRoute} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> <${Route} path="/telegram/:accountId"> ${(params) => html` <${TelegramRoute} accountId=${decodeURIComponent(params.accountId || "default")} onBack=${browseActions.exitSubScreen} /> `} <${Route} path="/telegram"> <${RouteRedirect} to="/telegram/default" /> <${Route} path="/providers"> <${RouteRedirect} to="/models" /> <${Route} path="/watchdog"> <${WatchdogRoute} statusData=${controllerState.sharedStatus} watchdogStatus=${controllerState.sharedWatchdogStatus} onRefreshStatuses=${controllerActions.refreshSharedStatuses} restartingGateway=${controllerState.restartingGateway} onRestartGateway=${controllerActions.handleGatewayRestart} restartSignal=${controllerState.gatewayRestartSignal} /> <${Route} path="/usage/:sessionId"> ${(params) => html` <${UsageRoute} sessionId=${decodeURIComponent( params.sessionId || "", )} onSetLocation=${setLocation} /> `} <${Route} path="/usage"> <${UsageRoute} onSetLocation=${setLocation} /> <${Route} path="/webhooks/:hookName"> ${(params) => html` <${WebhooksRoute} hookName=${decodeURIComponent(params.hookName || "")} routeHistoryRef=${browseState.routeHistoryRef} getCurrentPath=${getHashRouterPath} onSetLocation=${setLocation} onRestartRequired=${controllerActions.setRestartRequired} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> `} <${Route} path="/webhooks"> <${WebhooksRoute} routeHistoryRef=${browseState.routeHistoryRef} getCurrentPath=${getHashRouterPath} onSetLocation=${setLocation} onRestartRequired=${controllerActions.setRestartRequired} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> <${Route}> <${RouteRedirect} to="/general" />
`} <${ToastContainer} className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none" />
${footerVersion ? html`${footerVersion}` : null}
`; }; const rootElement = document.getElementById("app"); if (rootElement) { const appBootCounter = "__alphaclawSetupAppBootCount"; window[appBootCounter] = Number(window[appBootCounter] || 0) + 1; // Defensive: clear root so duplicate bootstraps cannot stack full app shells. render(null, rootElement); rootElement.replaceChildren(); render( html` <${Router} hook=${useHashLocation}> <${App} /> `, rootElement, ); } ================================================ FILE: lib/public/js/components/action-button.js ================================================ import { h } from "preact"; import htm from "htm"; import { LoadingSpinner } from "./loading-spinner.js"; const html = htm.bind(h); const kStaticToneClassByTone = { primary: "ac-btn-cyan", secondary: "ac-btn-secondary", success: "ac-btn-green", danger: "ac-btn-danger", ghost: "ac-btn-ghost", }; const getToneClass = (tone, isInteractive) => { if (tone === "subtle") { return isInteractive ? "border border-border text-fg-muted hover:text-body hover:border-fg-muted" : "border border-border text-fg-muted"; } if (tone === "neutral") { return isInteractive ? "border border-border text-fg-muted hover:text-body hover:border-fg-muted" : "border border-border text-fg-muted"; } if (tone === "warning") { return isInteractive ? "border border-yellow-500/35 text-status-warning-muted bg-yellow-500/10 hover:border-yellow-400/60 hover:text-status-warning hover:bg-yellow-500/15" : "border border-yellow-500/35 text-status-warning-muted bg-yellow-500/10"; } return kStaticToneClassByTone[tone] || kStaticToneClassByTone.primary; }; const kSizeClassBySize = { sm: "h-7 text-xs leading-none px-2.5 py-1 rounded-lg", md: "h-9 text-sm font-medium leading-none px-4 rounded-xl", lg: "h-10 text-sm font-medium leading-none px-5 rounded-lg", }; const kIconOnlySizeClassBySize = { sm: "h-7 w-7 p-0 rounded-lg", md: "h-9 w-9 p-0 rounded-xl", lg: "h-10 w-10 p-0 rounded-lg", }; export const ActionButton = ({ onClick, type = "button", disabled = false, loading = false, tone = "primary", size = "sm", idleLabel = "Action", loadingLabel = "Working...", loadingMode = "replace", className = "", idleIcon = null, idleIconClassName = "h-3 w-3", iconOnly = false, title = "", ariaLabel = "", }) => { const isDisabled = disabled || loading; const isInteractive = !isDisabled; const toneClass = getToneClass(tone, isInteractive); const sizeClass = iconOnly ? kIconOnlySizeClassBySize[size] || kIconOnlySizeClassBySize.sm : kSizeClassBySize[size] || kSizeClassBySize.sm; const loadingClass = loading ? `cursor-not-allowed ${ tone === "warning" ? "opacity-90 animate-pulse shadow-[0_0_0_1px_rgba(234,179,8,0.22),0_0_18px_rgba(234,179,8,0.12)]" : "opacity-80" }` : ""; const spinnerSizeClass = size === "md" || size === "lg" ? "h-4 w-4" : "h-3 w-3"; const isInlineLoading = loadingMode === "inline"; const IdleIcon = idleIcon; const idleContent = iconOnly && IdleIcon ? html`<${IdleIcon} className=${idleIconClassName} />` : IdleIcon ? html` <${IdleIcon} className=${idleIconClassName} /> ${idleLabel} ` : idleLabel; const currentLabel = loading && !isInlineLoading ? loadingLabel : idleContent; return html` `; }; ================================================ FILE: lib/public/js/components/add-channel-menu.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "./action-button.js"; import { AddLineIcon } from "./icons.js"; import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; const html = htm.bind(h); export const AddChannelMenu = ({ open = false, onClose = () => {}, onToggle = () => {}, triggerDisabled = false, channelIds = [], getChannelMeta = () => ({ label: "Channel", iconSrc: "" }), isChannelDisabled = () => false, onSelectChannel = () => {}, }) => html` <${OverflowMenu} open=${open} ariaLabel="Add channel" title="Add channel" onClose=${onClose} onToggle=${onToggle} renderTrigger=${({ onToggle: handleToggle, ariaLabel, title }) => html` <${ActionButton} onClick=${handleToggle} disabled=${triggerDisabled} loading=${false} loadingMode="inline" tone="subtle" size="sm" idleLabel="Add channel" loadingLabel="Opening..." idleIcon=${AddLineIcon} idleIconClassName="h-3.5 w-3.5" iconOnly=${true} title=${title} ariaLabel=${ariaLabel} /> `} > ${channelIds.map((channelId) => { const channelMeta = getChannelMeta(channelId); const disabled = !!isChannelDisabled(channelId); return html` <${OverflowMenuItem} key=${channelId} iconSrc=${channelMeta.iconSrc} disabled=${disabled} onClick=${() => onSelectChannel(channelId)} > ${channelMeta.label} `; })} `; ================================================ FILE: lib/public/js/components/agent-send-modal.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { ModalShell } from "./modal-shell.js"; import { ActionButton } from "./action-button.js"; import { PageHeader } from "./page-header.js"; import { CloseIcon } from "./icons.js"; import { SessionSelectField } from "./session-select-field.js"; import { useAgentSessions } from "../hooks/useAgentSessions.js"; const html = htm.bind(h); export const AgentSendModal = ({ visible = false, title = "Send to agent", messageLabel = "Message", messageRows = 8, initialMessage = "", resetKey = "", submitLabel = "Send message", loadingLabel = "Sending...", cancelLabel = "Cancel", onClose = () => {}, onSubmit = async () => true, sessionFilter = undefined, }) => { const { sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading: loadingSessions, error: loadError, } = useAgentSessions({ enabled: visible, filter: sessionFilter }); const [messageText, setMessageText] = useState(""); const [sending, setSending] = useState(false); useEffect(() => { if (!visible) return; setMessageText(String(initialMessage || "")); }, [visible, initialMessage, resetKey]); const handleSend = async () => { if (!selectedSession || sending) return; const trimmedMessage = String(messageText || "").trim(); if (!trimmedMessage) return; setSending(true); try { const shouldClose = await onSubmit({ selectedSession, selectedSessionKey, message: trimmedMessage, }); if (shouldClose !== false) { onClose(); } } finally { setSending(false); } }; return html` <${ModalShell} visible=${visible} onClose=${() => { if (sending) return; onClose(); }} panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4" > <${PageHeader} title=${title} actions=${html` `} /> <${SessionSelectField} label="Send to session" sessions=${sessions} selectedSessionKey=${selectedSessionKey} onChangeSessionKey=${setSelectedSessionKey} disabled=${loadingSessions || sending} loading=${loadingSessions} error=${loadError} emptyOptionLabel="No sessions available" />
<${ActionButton} onClick=${onClose} disabled=${sending} tone="secondary" size="md" idleLabel=${cancelLabel} /> <${ActionButton} onClick=${handleSend} disabled=${!selectedSession || loadingSessions || !!loadError || !String(messageText || "").trim()} loading=${sending} tone="primary" size="md" idleLabel=${submitLabel} loadingLabel=${loadingLabel} />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js ================================================ import { h } from "preact"; import htm from "htm"; import { Badge } from "../../badge.js"; import { ChannelAccountStatusBadge } from "../../channel-account-status-badge.js"; import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js"; const html = htm.bind(h); export const ChannelItemTrailing = ({ item = {}, menuOpenId = "", setMenuOpenId = () => {}, openDeleteChannelDialog = () => {}, openEditChannelModal = () => {}, requestBindAccount = () => {}, onSetLocation = () => {}, }) => { const { accountData = {}, accountId = "", accountStatusInfo = {}, canNavigateToOwnerAgent = false, channel = "", ownerAgentId = "", ownerAgentName = "", isAvailable = false, isOwned = false, } = item; let statusTrailing = null; if (isOwned) { statusTrailing = accountStatusInfo?.status === "paired" ? html`<${ChannelAccountStatusBadge} status=${accountStatusInfo?.status} ownerAgentName=${ownerAgentName} showAgentBadge=${true} channelId=${channel} pairedCount=${accountStatusInfo?.paired ?? 0} />` : html`<${ChannelAccountStatusBadge} status=${accountStatusInfo?.status} ownerAgentName="" showAgentBadge=${false} channelId=${channel} pairedCount=${accountStatusInfo?.paired ?? 0} />`; } else if (isAvailable) { statusTrailing = html` `; } else { statusTrailing = html` ${canNavigateToOwnerAgent ? html` ` : html`<${Badge} tone="neutral">${ownerAgentName || "Bound elsewhere"}`} `; } const showBindAction = accountData.isBoundElsewhere; const canEditOrDelete = !accountData.isBoundElsewhere; return html`
${statusTrailing} <${OverflowMenu} open=${menuOpenId === `${channel}:${accountId}`} ariaLabel="Open channel actions" title="Open channel actions" onClose=${() => setMenuOpenId("")} onToggle=${() => setMenuOpenId((current) => current === `${channel}:${accountId}` ? "" : `${channel}:${accountId}`, )} > ${canEditOrDelete ? html` <${OverflowMenuItem} onClick=${() => openEditChannelModal(accountData)} > Edit ` : null} ${showBindAction ? html` <${OverflowMenuItem} onClick=${() => requestBindAccount(accountData)} > Bind ` : null} ${canEditOrDelete ? html` <${OverflowMenuItem} className="text-status-error hover:text-status-error" onClick=${() => openDeleteChannelDialog(accountData)} > Delete ` : null}
`; }; export const ChannelCardItem = ({ item = {}, channelMeta = {}, menuOpenId = "", setMenuOpenId = () => {}, openDeleteChannelDialog = () => {}, openEditChannelModal = () => {}, requestBindAccount = () => {}, onSetLocation = () => {}, }) => { const canOpenWorkspace = !!item?.canOpenWorkspace; const accountId = String(item?.accountId || "").trim() || "default"; return html`
onSetLocation(`/telegram/${encodeURIComponent(accountId)}`) : undefined} > ${channelMeta?.iconSrc ? html` ` : null} ${item?.label || channelMeta?.label || "Channel"} ${canOpenWorkspace ? html` Workspace ` : null} <${ChannelItemTrailing} item=${item} menuOpenId=${menuOpenId} setMenuOpenId=${setMenuOpenId} openDeleteChannelDialog=${openDeleteChannelDialog} openEditChannelModal=${openEditChannelModal} requestBindAccount=${requestBindAccount} onSetLocation=${onSetLocation} />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-bindings-section/helpers.js ================================================ import { isImplicitDefaultAccount, resolveChannelAccountLabel, } from "../../../lib/channel-accounts.js"; export const announceBindingsChanged = (agentId) => { window.dispatchEvent( new CustomEvent("alphaclaw:agent-bindings-changed", { detail: { agentId: String(agentId || "").trim() }, }), ); }; export const announceRestartRequired = () => { window.dispatchEvent(new CustomEvent("alphaclaw:restart-required")); }; export { resolveChannelAccountLabel }; export const getChannelItemSortRank = (item = {}) => { if (item.isAwaitingPairing) return 99; if (item.isOwned) return 0; if (item.isUnconfigured) return 3; if (item.isAvailable) return 1; return 2; }; export const getAccountStatusInfo = ({ statusInfo, accountId }) => { const normalizedAccountId = String(accountId || "").trim() || "default"; const accountStatuses = statusInfo?.accounts && typeof statusInfo.accounts === "object" ? statusInfo.accounts : null; if (accountStatuses?.[normalizedAccountId]) { return accountStatuses[normalizedAccountId]; } if (normalizedAccountId === "default" && statusInfo) { return statusInfo; } return null; }; export const getResolvedAccountStatusInfo = ({ account, statusInfo, accountId, }) => { const accountStatus = String(account?.status || "").trim(); if (accountStatus) { return { status: accountStatus, paired: Number(account?.paired || 0), }; } return getAccountStatusInfo({ statusInfo, accountId }); }; export { isImplicitDefaultAccount }; export const canAgentBindAccount = ({ accountId, boundAgentId, agentId, isDefaultAgent, }) => { const normalizedBoundAgentId = String(boundAgentId || "").trim(); if (normalizedBoundAgentId) { return normalizedBoundAgentId === String(agentId || "").trim(); } if (isImplicitDefaultAccount({ accountId, boundAgentId })) { return !!isDefaultAgent; } return true; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-bindings-section/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { isChannelProviderDisabledForAdd } from "../../../lib/channel-provider-availability.js"; import { AddChannelMenu } from "../../add-channel-menu.js"; import { ActionButton } from "../../action-button.js"; import { ALL_CHANNELS, ChannelsCard, getChannelMeta } from "../../channels.js"; import { ConfirmDialog } from "../../confirm-dialog.js"; import { AddLineIcon } from "../../icons.js"; import { CreateChannelModal } from "../create-channel-modal.js"; import { ChannelCardItem } from "./channel-item-trailing.js"; import { useAgentBindings } from "./use-agent-bindings.js"; import { useChannelItems } from "./use-channel-items.js"; const html = htm.bind(h); export const AgentBindingsSection = ({ agent = {}, agents = [], onSetLocation = () => {}, }) => { const { agentId, agentNameMap, channelStatus, channels, configuredChannelMap, configuredChannels, createLoadingLabel, createProvider, defaultAgentId, deletingAccount, editingAccount, handleCreateChannel, handleDeleteChannel, handleQuickBind, handleUpdateChannel, isDefaultAgent, loading, menuOpenId, openCreateChannelModal, openDeleteChannelDialog, openEditChannelModal, pendingBindAccount, requestBindAccount, saving, setCreateProvider, setDeletingAccount, setEditingAccount, setMenuOpenId, setPendingBindAccount, setShowCreateModal, showCreateModal, } = useAgentBindings({ agent, agents }); const { mergedChannelItems } = useChannelItems({ agentId, agentNameMap, channelStatus, configuredChannelMap, configuredChannels, defaultAgentId, isDefaultAgent, }); return html`
${loading ? html` <${ChannelsCard} title="Channels" items=${[]} loadingLabel="Loading channels..." actions=${html`
<${ActionButton} onClick=${() => {}} disabled=${true} tone="subtle" size="sm" idleIcon=${AddLineIcon} idleIconClassName="h-3.5 w-3.5" iconOnly=${true} title="Add channel" ariaLabel="Add channel" idleLabel="Add channel" />
`} /> ` : html`
<${ChannelsCard} title="Channels" items=${mergedChannelItems} loadingLabel="No channels assigned to this agent." renderItem=${({ item, channelMeta }) => { if (String(item?.id || "").trim() === "__assigned_elsewhere_toggle") { return null; } return html`<${ChannelCardItem} item=${item} channelMeta=${channelMeta} menuOpenId=${menuOpenId} setMenuOpenId=${setMenuOpenId} openDeleteChannelDialog=${openDeleteChannelDialog} openEditChannelModal=${openEditChannelModal} requestBindAccount=${requestBindAccount} onSetLocation=${onSetLocation} />`; }} actions=${html` <${AddChannelMenu} open=${menuOpenId === "__create_channel"} onClose=${() => setMenuOpenId("")} onToggle=${() => setMenuOpenId((current) => current === "__create_channel" ? "" : "__create_channel", )} triggerDisabled=${saving} channelIds=${ALL_CHANNELS} getChannelMeta=${getChannelMeta} isChannelDisabled=${(channelId) => isChannelProviderDisabledForAdd({ configuredChannelMap, provider: channelId, })} onSelectChannel=${openCreateChannelModal} /> `} />
`} <${CreateChannelModal} visible=${showCreateModal} loading=${saving} createLoadingLabel=${createLoadingLabel} agents=${agents} existingChannels=${channels} initialAgentId=${agentId} initialProvider=${createProvider} onClose=${() => { setShowCreateModal(false); setCreateProvider(""); }} onSubmit=${handleCreateChannel} /> <${CreateChannelModal} visible=${!!editingAccount} loading=${saving} agents=${agents} existingChannels=${channels} mode="edit" account=${editingAccount} initialAgentId=${String(editingAccount?.ownerAgentId || agentId || "").trim()} initialProvider=${String(editingAccount?.provider || "").trim()} onClose=${() => setEditingAccount(null)} onSubmit=${handleUpdateChannel} /> <${ConfirmDialog} visible=${!!pendingBindAccount} title=${`Bind ${String(pendingBindAccount?.name || "this channel").trim()} to ${String(agent?.name || agentId).trim()}?`} message="" details=${pendingBindAccount ? html`

This will remove access for ${String( pendingBindAccount?.ownerAgentName || "the other agent", ).trim()} to this channel.

` : null} confirmLabel="Bind channel" confirmLoadingLabel="Binding..." confirmTone="warning" confirmLoading=${saving} onConfirm=${() => handleQuickBind(pendingBindAccount)} onCancel=${() => { if (saving) return; setPendingBindAccount(null); }} /> <${ConfirmDialog} visible=${!!deletingAccount} title="Delete channel?" message=${`Remove ${String(deletingAccount?.name || "this channel").trim()} from your configured channels?`} confirmLabel="Delete" confirmLoadingLabel="Deleting..." confirmTone="warning" confirmLoading=${saving} onConfirm=${handleDeleteChannel} onCancel=${() => { if (saving) return; setDeletingAccount(null); }} />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js ================================================ import { useCallback, useEffect, useMemo, useState, } from "preact/hooks"; import { deleteChannelAccount, fetchChannelAccounts, fetchStatus, updateChannelAccount, } from "../../../lib/api.js"; import { createChannelAccountWithProgress } from "../../../lib/channel-create-operation.js"; import { showToast } from "../../toast.js"; import { announceBindingsChanged, announceRestartRequired } from "./helpers.js"; export const useAgentBindings = ({ agent = {}, agents = [] }) => { const [channels, setChannels] = useState([]); const [channelStatus, setChannelStatus] = useState({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [createLoadingLabel, setCreateLoadingLabel] = useState("Creating..."); const [showCreateModal, setShowCreateModal] = useState(false); const [createProvider, setCreateProvider] = useState(""); const [menuOpenId, setMenuOpenId] = useState(""); const [editingAccount, setEditingAccount] = useState(null); const [deletingAccount, setDeletingAccount] = useState(null); const [pendingBindAccount, setPendingBindAccount] = useState(null); const agentId = String(agent?.id || "").trim(); const isDefaultAgent = !!agent?.default; const defaultAgentId = useMemo( () => String(agents.find((entry) => entry?.default)?.id || "").trim(), [agents], ); const agentNameMap = useMemo( () => new Map( agents.map((entry) => [ String(entry?.id || "").trim(), String(entry?.name || "").trim() || String(entry?.id || "").trim(), ]), ), [agents], ); const load = useCallback( async ({ includeStatus = true } = {}) => { setLoading(true); try { const requests = [ fetchChannelAccounts(), includeStatus ? fetchStatus() : Promise.resolve(null), ]; const [channelsResult, statusResult] = await Promise.all(requests); setChannels( Array.isArray(channelsResult?.channels) ? channelsResult.channels : [], ); if (includeStatus && statusResult) { setChannelStatus(statusResult?.channels || {}); } } finally { setLoading(false); } }, [], ); useEffect(() => { if (!agentId) return; load().catch(() => {}); }, [agentId, load]); useEffect(() => { const handlePairingsChanged = (event) => { const changedAgentId = String(event?.detail?.agentId || "").trim(); if (changedAgentId && changedAgentId !== agentId) return; load({ includeStatus: true }).catch(() => {}); }; window.addEventListener("alphaclaw:pairings-changed", handlePairingsChanged); return () => { window.removeEventListener( "alphaclaw:pairings-changed", handlePairingsChanged, ); }; }, [agentId, load]); const configuredChannels = useMemo( () => channels.filter( (entry) => String(entry?.channel || "").trim() && Array.isArray(entry?.accounts) && entry.accounts.length > 0, ), [channels], ); const configuredChannelMap = useMemo( () => new Map( configuredChannels.map((entry) => [ String(entry.channel || "").trim(), entry, ]), ), [configuredChannels], ); const openCreateChannelModal = (channelId = "") => { setMenuOpenId(""); setCreateProvider(String(channelId || "").trim()); setShowCreateModal(true); }; const openEditChannelModal = (account) => { setMenuOpenId(""); setEditingAccount(account); }; const openDeleteChannelDialog = (account) => { setMenuOpenId(""); setDeletingAccount(account); }; const handleCreateChannel = async (payload) => { setSaving(true); setCreateLoadingLabel("Creating..."); try { const result = await createChannelAccountWithProgress({ payload, onPhase: (label) => { setCreateLoadingLabel(String(label || "").trim() || "Creating..."); }, }); announceBindingsChanged( String(result?.binding?.agentId || payload.agentId || "").trim(), ); showToast("Channel added", "success"); await load({ includeStatus: false }); setShowCreateModal(false); setCreateProvider(""); } catch (error) { showToast(error.message || "Could not add channel", "error"); } finally { setSaving(false); setCreateLoadingLabel("Creating..."); } }; const handleUpdateChannel = async (payload) => { setSaving(true); try { const result = await updateChannelAccount(payload); setEditingAccount(null); announceBindingsChanged(String(payload.agentId || "").trim()); showToast("Channel updated", "success"); if (result?.restartRequired) { announceRestartRequired(); } await load(); } catch (error) { showToast(error.message || "Could not update channel", "error"); } finally { setSaving(false); } }; const handleDeleteChannel = async () => { if (!deletingAccount) return; setSaving(true); try { await deleteChannelAccount({ provider: deletingAccount.provider, accountId: deletingAccount.id, }); setDeletingAccount(null); announceBindingsChanged(agentId); showToast("Channel deleted", "success"); await load({ includeStatus: false }); } catch (error) { showToast(error.message || "Could not delete channel", "error"); } finally { setSaving(false); } }; const handleQuickBind = async (account) => { if (!account) return; setSaving(true); try { await updateChannelAccount({ provider: account.provider, accountId: account.id, name: account.name, agentId, }); setMenuOpenId(""); setPendingBindAccount(null); announceBindingsChanged(agentId); showToast("Channel bound", "success"); await load(); } catch (error) { showToast(error.message || "Could not bind channel", "error"); } finally { setSaving(false); } }; const requestBindAccount = (account) => { if (!account) return; const ownerAgentId = String(account?.ownerAgentId || "").trim(); const ownerAgentName = String(account?.ownerAgentName || "").trim(); if (ownerAgentId && ownerAgentId !== agentId && ownerAgentName) { setMenuOpenId(""); setPendingBindAccount(account); return; } handleQuickBind(account); }; return { agentId, agentNameMap, channelStatus, channels, configuredChannelMap, configuredChannels, createLoadingLabel, createProvider, defaultAgentId, deletingAccount, editingAccount, handleCreateChannel, handleDeleteChannel, handleQuickBind, handleUpdateChannel, isDefaultAgent, loading, menuOpenId, openCreateChannelModal, openDeleteChannelDialog, openEditChannelModal, pendingBindAccount, requestBindAccount, saving, setCreateProvider, setDeletingAccount, setEditingAccount, setMenuOpenId, setPendingBindAccount, setShowCreateModal, showCreateModal, }; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js ================================================ import { h } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import htm from "htm"; import { canAgentBindAccount, getChannelItemSortRank, getResolvedAccountStatusInfo, isImplicitDefaultAccount, resolveChannelAccountLabel, } from "./helpers.js"; const html = htm.bind(h); export const useChannelItems = ({ agentId = "", agentNameMap = new Map(), channelStatus = {}, configuredChannelMap = new Map(), configuredChannels = [], defaultAgentId = "", isDefaultAgent = false, }) => { const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false); const channelItemData = useMemo(() => { const channelOrderMap = new Map( configuredChannels.map((entry, index) => [ String(entry?.channel || "").trim(), index, ]), ); const accountOrderMap = new Map( configuredChannels.flatMap((entry) => (Array.isArray(entry?.accounts) ? entry.accounts : []).map( (account, accountIndex) => [ `${String(entry?.channel || "").trim()}:${String(account?.id || "").trim() || "default"}`, accountIndex, ], ), ), ); const channelIds = Array.from( new Set([ ...configuredChannels.map((entry) => String(entry.channel || "").trim()), ]), ).filter(Boolean); return channelIds .flatMap((channelId) => { const configuredChannel = configuredChannelMap.get(channelId); const statusInfo = channelStatus?.[channelId] || null; const accounts = Array.isArray(configuredChannel?.accounts) ? configuredChannel.accounts : []; if (!configuredChannel && !statusInfo) return []; return accounts.map((account) => { const accountId = String(account?.id || "").trim() || "default"; const boundAgentId = String(account?.boundAgentId || "").trim(); const accountStatusInfo = getResolvedAccountStatusInfo({ account, statusInfo, accountId, }); const isImplicitDefaultOwned = isDefaultAgent && isImplicitDefaultAccount({ accountId, boundAgentId }); const isOwned = boundAgentId === agentId || isImplicitDefaultOwned; const isImplicitDefaultElsewhere = !isDefaultAgent && isImplicitDefaultAccount({ accountId, boundAgentId }); const isAvailable = canAgentBindAccount({ accountId, boundAgentId, agentId, isDefaultAgent, }); const ownerAgentId = boundAgentId || (isImplicitDefaultAccount({ accountId, boundAgentId }) ? defaultAgentId : ""); const ownerAgentName = String( agentNameMap.get(ownerAgentId) || ownerAgentId || "", ).trim(); const canNavigateToOwnerAgent = !!ownerAgentId && ownerAgentId !== agentId && !!ownerAgentName; const canOpenWorkspace = channelId === "telegram" && isOwned && accountStatusInfo?.status === "paired"; const accountData = { id: accountId, provider: channelId, name: resolveChannelAccountLabel({ channelId, account }), rawName: String(account?.name || "").trim(), ownerAgentId, ownerAgentName, boundAgentId, isOwned, envKey: String(account?.envKey || "").trim(), token: String(account?.token || "").trim(), isAvailable, isBoundElsewhere: !isOwned && (!isAvailable || isImplicitDefaultElsewhere || !!ownerAgentId), }; return { id: `${channelId}:${accountId}`, channel: channelId, accountId, channelOrder: Number(channelOrderMap.get(channelId) ?? 9999), accountOrder: Number( accountOrderMap.get(`${channelId}:${accountId}`) ?? 9999, ), label: resolveChannelAccountLabel({ channelId, account }), isAwaitingPairing: accountStatusInfo?.status !== "paired", canOpenWorkspace, canNavigateToOwnerAgent, ownerAgentId, ownerAgentName, accountStatusInfo, accountData, isOwned, isAvailable, dimmedLabel: accountData.isBoundElsewhere, isBoundElsewhere: accountData.isBoundElsewhere, }; }); }) .filter(Boolean) .sort((a, b) => { const rankDiff = getChannelItemSortRank(a) - getChannelItemSortRank(b); if (rankDiff !== 0) return rankDiff; const channelOrderDiff = Number(a?.channelOrder ?? 9999) - Number(b?.channelOrder ?? 9999); if (channelOrderDiff !== 0) return channelOrderDiff; const accountOrderDiff = Number(a?.accountOrder ?? 9999) - Number(b?.accountOrder ?? 9999); if (accountOrderDiff !== 0) return accountOrderDiff; return String(a?.label || "").localeCompare(String(b?.label || "")); }); }, [ agentId, agentNameMap, channelStatus, configuredChannelMap, configuredChannels, defaultAgentId, isDefaultAgent, ]); const visibleChannelItems = channelItemData.filter( (item) => !item?.isBoundElsewhere, ); const assignedElsewhereItems = channelItemData.filter( (item) => !!item?.isBoundElsewhere, ); useEffect(() => { if (assignedElsewhereItems.length === 0) { setShowAssignedElsewhere(false); return; } if (visibleChannelItems.length === 0) { setShowAssignedElsewhere(true); } }, [agentId, assignedElsewhereItems.length, visibleChannelItems.length]); const mergedChannelItems = useMemo(() => { const baseItems = [...visibleChannelItems]; if (assignedElsewhereItems.length === 0) return baseItems; baseItems.push({ id: "__assigned_elsewhere_toggle", label: html` Assigned elsewhere `, labelClassName: "text-xs", clickable: true, onClick: () => setShowAssignedElsewhere((current) => !current), dimmedLabel: true, trailing: html` ${assignedElsewhereItems.length} `, }); if (showAssignedElsewhere) { baseItems.push(...assignedElsewhereItems); } return baseItems; }, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]); return { mergedChannelItems, }; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-detail-panel.js ================================================ import { h } from "preact"; import { useState, useCallback, useMemo } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { Badge } from "../badge.js"; import { PillTabs } from "../pill-tabs.js"; import { PopActions } from "../pop-actions.js"; import { AgentOverview } from "./agent-overview/index.js"; import { AgentToolsPanel } from "./agent-tools/index.js"; import { useAgentTools } from "./agent-tools/use-agent-tools.js"; const html = htm.bind(h); const kDetailTabs = [ { label: "Overview", value: "overview" }, { label: "Tools", value: "tools" }, ]; const PencilIcon = ({ className = "w-3.5 h-3.5" }) => html` `; export const AgentDetailPanel = ({ agent = null, agents = [], activeTab = "overview", saving = false, onUpdateAgent = async () => {}, onSetLocation = () => {}, onSelectTab = () => {}, onEdit = () => {}, onDelete = () => {}, onSetDefault = () => {}, onOpenWorkspace = () => {}, }) => { const tools = useAgentTools({ agent: agent || {} }); const [savingTools, setSavingTools] = useState(false); const handleSaveTools = useCallback(async () => { if (!agent) return; setSavingTools(true); try { const nextAgent = await onUpdateAgent( agent.id, { tools: tools.toolsConfig }, "Tool access updated", ); tools.markSaved(nextAgent?.tools || tools.toolsConfig); } catch { // toast handled by parent } finally { setSavingTools(false); } }, [agent, tools.toolsConfig, tools.markSaved, onUpdateAgent]); const isSaving = saving || savingTools; const toolsSummary = useMemo(() => ({ profile: tools.profile, enabledCount: (tools.toolStates || []).filter((t) => t.enabled).length, totalCount: (tools.toolStates || []).length, }), [tools.profile, tools.toolStates]); if (!agent) { return html`
Select an agent to view details
`; } return html`
${agent.name || agent.id} ${agent.default ? html`<${Badge} tone="cyan">Default` : null}
${agent.id}
<${PopActions} visible=${tools.dirty}> <${ActionButton} onClick=${tools.reset} disabled=${isSaving} tone="secondary" size="sm" idleLabel="Cancel" className="text-xs" /> <${ActionButton} onClick=${handleSaveTools} disabled=${isSaving} loading=${isSaving} loadingMode="inline" tone="primary" size="sm" idleLabel="Save changes" loadingLabel="Saving…" className="text-xs" />
<${PillTabs} tabs=${kDetailTabs} activeTab=${activeTab} onSelectTab=${onSelectTab} className="flex items-center gap-2 pt-6" />
${activeTab === "overview" ? html` <${AgentOverview} agent=${agent} agents=${agents} saving=${saving} toolsSummary=${toolsSummary} onUpdateAgent=${onUpdateAgent} onSetLocation=${onSetLocation} onOpenWorkspace=${onOpenWorkspace} onSwitchToModels=${() => onSetLocation("/models")} onSwitchToTools=${() => onSelectTab("tools")} onSetDefault=${onSetDefault} onDelete=${onDelete} /> ` : html` <${AgentToolsPanel} agent=${agent} tools=${tools} /> `}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-identity-section.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; const html = htm.bind(h); const kPropertyRowClass = "flex items-start justify-between gap-4 py-2.5 border-b border-border last:border-b-0"; const kLabelClass = "text-xs text-fg-muted shrink-0 w-28"; const kValueClass = "text-sm text-body text-right min-w-0 break-all"; const normalizeIdentity = (identity = {}) => ({ name: String(identity?.name || "").trim(), emoji: String(identity?.emoji || "").trim(), avatar: String(identity?.avatar || "").trim(), theme: String(identity?.theme || "").trim(), }); export const AgentIdentitySection = ({ agent = {}, saving = false, onUpdateAgent = async () => {}, }) => { const [editing, setEditing] = useState(false); const [form, setForm] = useState(() => normalizeIdentity(agent.identity)); const [error, setError] = useState(""); useEffect(() => { setEditing(false); setError(""); setForm(normalizeIdentity(agent.identity)); }, [agent.id, agent.identity]); const identity = normalizeIdentity(agent.identity); const updateField = (key, value) => { setForm((current) => ({ ...current, [key]: value, })); }; const handleSave = async () => { setError(""); try { const nextIdentity = normalizeIdentity(form); await onUpdateAgent(String(agent.id || "").trim(), { identity: nextIdentity, }); setEditing(false); } catch (nextError) { setError(nextError.message || "Could not save identity"); } }; const handleCancel = () => { setEditing(false); setError(""); setForm(normalizeIdentity(agent.identity)); }; return html`

Identity

${editing ? html`
<${ActionButton} onClick=${handleCancel} disabled=${saving} tone="secondary" size="sm" idleLabel="Cancel" /> <${ActionButton} onClick=${handleSave} disabled=${saving} loading=${saving} tone="primary" size="sm" idleLabel="Save" loadingLabel="Saving..." />
` : html` <${ActionButton} onClick=${() => setEditing(true)} disabled=${saving} tone="secondary" size="sm" idleLabel="Edit identity" /> `}
${editing ? html`
${error ? html`

${error}

` : null}
` : html`
Name ${identity.name || html``}
Emoji ${identity.emoji || html``}
Avatar ${identity.avatar || html``}
Theme ${identity.theme || html``}
`}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ChannelOperationsPanel } from "../../channel-operations-panel.js"; import { ManageCard } from "./manage-card.js"; import { AgentModelCard } from "./model-card.js"; import { AgentToolsCard } from "./tools-card.js"; import { WorkspaceCard } from "./workspace-card.js"; const html = htm.bind(h); export const AgentOverview = ({ agent = {}, agents = [], saving = false, toolsSummary = {}, onUpdateAgent = async () => {}, onSetLocation = () => {}, onOpenWorkspace = () => {}, onSwitchToModels = () => {}, onSwitchToTools = () => {}, onSetDefault = () => {}, onDelete = () => {}, }) => { const isMain = String(agent.id || "") === "main"; const showManageSection = !agent.default || !isMain; return html`
<${WorkspaceCard} agent=${agent} onOpenWorkspace=${onOpenWorkspace} /> <${AgentModelCard} agent=${agent} saving=${saving} onUpdateAgent=${onUpdateAgent} onSwitchToModels=${onSwitchToModels} /> <${AgentToolsCard} profile=${toolsSummary.profile || "full"} enabledCount=${toolsSummary.enabledCount || 0} totalCount=${toolsSummary.totalCount || 0} onSwitchToTools=${onSwitchToTools} /> <${ChannelOperationsPanel} agent=${agent} agents=${agents} onSetLocation=${onSetLocation} /> ${showManageSection ? html` <${ManageCard} agent=${agent} saving=${saving} onSetDefault=${onSetDefault} onDelete=${onDelete} /> ` : null}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/manage-card.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../../action-button.js"; const html = htm.bind(h); export const ManageCard = ({ agent = {}, saving = false, onSetDefault = () => {}, onDelete = () => {}, }) => { const isMain = String(agent.id || "") === "main"; return html`

Manage

${!agent.default ? html` <${ActionButton} onClick=${() => onSetDefault(agent.id)} disabled=${saving} tone="secondary" size="sm" idleLabel="Set as default" /> ` : null} ${!isMain ? html` <${ActionButton} onClick=${() => onDelete(agent)} disabled=${saving} tone="danger" size="sm" idleLabel="Delete agent" /> ` : null}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/model-card.js ================================================ import { h } from "preact"; import htm from "htm"; import { Badge } from "../../badge.js"; import { LoadingSpinner } from "../../loading-spinner.js"; import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js"; import { getModelDisplayLabel, SearchableModelPicker, } from "../../models-tab/model-picker.js"; import { useModelCard } from "./use-model-card.js"; const html = htm.bind(h); export const AgentModelCard = ({ agent = {}, saving = false, onUpdateAgent = async () => {}, onSwitchToModels = () => {}, }) => { const { authorizedModelOptions, canEditModel, effectiveModel, effectiveModelEntry, handleClearModelOverride, handleSelectModel, hasDistinctModelOverride, loading, menuOpen, modelEntries, popularModels, remainingModelOptions, setMenuOpen, updatingModel, } = useModelCard({ agent, onUpdateAgent, }); return html`

Model

${loading ? null : html`
${effectiveModelEntry && !hasDistinctModelOverride ? html`<${Badge} tone="neutral">Inherited` : null} <${OverflowMenu} open=${menuOpen} ariaLabel="Open model actions" title="Open model actions" onClose=${() => setMenuOpen(false)} onToggle=${() => setMenuOpen((current) => !current)} > ${hasDistinctModelOverride ? html` <${OverflowMenuItem} onClick=${() => { setMenuOpen(false); handleClearModelOverride(); }} > Inherit from defaults ` : null} <${OverflowMenuItem} onClick=${() => { setMenuOpen(false); onSwitchToModels(); }} > Manage models
`}
${loading ? html`
<${LoadingSpinner} className="h-4 w-4" /> Loading model settings...
` : modelEntries.length === 0 ? html`

No authorized models available yet. Add one from the Models tab first.

` : html`
${modelEntries.map( (entry) => html`
${getModelDisplayLabel(entry)} ${entry.key === effectiveModel ? html`<${Badge} tone="cyan">Primary` : html` `}
`, )}
`} ${loading ? null : remainingModelOptions.length > 0 ? html`
<${SearchableModelPicker} options=${remainingModelOptions} popularModels=${popularModels} placeholder=${authorizedModelOptions.length > 0 ? "Add model..." : "No authorized models available"} onSelect=${handleSelectModel} disabled=${saving || updatingModel || !canEditModel || remainingModelOptions.length === 0} /> ${authorizedModelOptions.length === 0 ? html`

Add and authorize models from the Models tab before assigning one here.

` : html`

Only models that already have working auth are available here.

`}
` : null}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/tools-card.js ================================================ import { h } from "preact"; import htm from "htm"; import { kProfileLabels } from "../agent-tools/tool-catalog.js"; const html = htm.bind(h); export const AgentToolsCard = ({ profile = "full", enabledCount = 0, totalCount = 0, onSwitchToTools = () => {}, }) => { const profileLabel = kProfileLabels[profile] || profile; return html`

Tools

{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSwitchToTools(); } }} > ${profileLabel} ${enabledCount}/${totalCount} enabled
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/use-model-card.js ================================================ import { useEffect, useMemo, useState } from "preact/hooks"; import { useModels } from "../../models-tab/use-models.js"; import { buildProviderHasAuth, buildSyntheticModelEntry, getModelCatalogProvider, getModelsTabAuthProvider, getProviderSortIndex, } from "../../models-tab/model-picker.js"; const resolveModelDisplay = (model) => { if (!model) return null; if (typeof model === "string") return model; return model.primary || null; }; const resolveCatalogModel = (catalog = [], modelKey = "") => catalog.find( (model) => String(model?.key || "").trim() === String(modelKey || "").trim(), ) || null; export const useModelCard = ({ agent = {}, onUpdateAgent = async () => {}, }) => { const [updatingModel, setUpdatingModel] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const { catalog, primary: defaultPrimaryModel, configuredModels, authProfiles, codexStatus, loading: loadingModels, ready: modelsReady, } = useModels(); const explicitModel = resolveModelDisplay(agent.model); const effectiveModel = explicitModel || defaultPrimaryModel || ""; const hasDistinctModelOverride = !!explicitModel && String(explicitModel).trim() !== String(defaultPrimaryModel || "").trim(); const providerHasAuth = useMemo( () => buildProviderHasAuth({ authProfiles, codexStatus }), [authProfiles, codexStatus], ); const authorizedModelOptions = useMemo( () => Object.keys(configuredModels || {}) .map( (modelKey) => resolveCatalogModel(catalog, modelKey) || buildSyntheticModelEntry(modelKey), ) .filter((model) => { const provider = getModelsTabAuthProvider(model.key); return !!providerHasAuth[provider]; }) .sort((left, right) => { const providerCompare = getProviderSortIndex(getModelCatalogProvider(left)) - getProviderSortIndex(getModelCatalogProvider(right)); if (providerCompare !== 0) return providerCompare; return String(left?.label || left?.key).localeCompare( String(right?.label || right?.key), ); }), [catalog, configuredModels, providerHasAuth], ); const effectiveModelEntry = useMemo( () => resolveCatalogModel(catalog, effectiveModel) || (effectiveModel ? buildSyntheticModelEntry(effectiveModel) : null), [catalog, effectiveModel], ); const popularModels = useMemo( () => authorizedModelOptions.filter((model) => { const normalizedProvider = getModelCatalogProvider(model); return ( normalizedProvider === "anthropic" || normalizedProvider === "openai" ); }), [authorizedModelOptions], ); const modelEntries = useMemo(() => { if (!effectiveModelEntry) return []; const currentKey = String(effectiveModelEntry?.key || "").trim(); const rest = authorizedModelOptions.filter( (model) => String(model?.key || "").trim() !== currentKey, ); return [effectiveModelEntry, ...rest]; }, [authorizedModelOptions, effectiveModelEntry]); const modelEntryKeySet = useMemo( () => new Set( modelEntries .map((entry) => String(entry?.key || "").trim()) .filter(Boolean), ), [modelEntries], ); const remainingModelOptions = useMemo( () => authorizedModelOptions.filter( (model) => !modelEntryKeySet.has(String(model?.key || "").trim()), ), [authorizedModelOptions, modelEntryKeySet], ); const handleSelectModel = async (modelKey) => { const normalizedModelKey = String(modelKey || "").trim(); if (!normalizedModelKey || normalizedModelKey === effectiveModel) return; setUpdatingModel(true); try { await onUpdateAgent( String(agent.id || "").trim(), { model: { primary: normalizedModelKey }, }, "Agent model updated", ); } finally { setUpdatingModel(false); } }; const handleClearModelOverride = async () => { if (!hasDistinctModelOverride) return; setUpdatingModel(true); try { await onUpdateAgent( String(agent.id || "").trim(), { model: null, }, "Agent model reset to default", ); } finally { setUpdatingModel(false); } }; return { authorizedModelOptions, canEditModel: modelsReady && !loadingModels, effectiveModel, effectiveModelEntry, handleClearModelOverride, handleSelectModel, hasDistinctModelOverride, loading: !modelsReady || loadingModels, menuOpen, modelEntries, popularModels, remainingModelOptions, setMenuOpen, updatingModel, }; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/use-workspace-card.js ================================================ import { useEffect, useState } from "preact/hooks"; import { fetchAgentWorkspaceSize } from "../../../lib/api.js"; export const useWorkspaceCard = ({ agent = {} }) => { const [workspaceSizeBytes, setWorkspaceSizeBytes] = useState(null); const [workspaceSizeExists, setWorkspaceSizeExists] = useState(true); const [loadingWorkspaceSize, setLoadingWorkspaceSize] = useState(false); useEffect(() => { let cancelled = false; const agentId = String(agent?.id || "").trim(); const workspacePath = String(agent?.workspace || "").trim(); if (!agentId || !workspacePath) { setWorkspaceSizeBytes(null); setWorkspaceSizeExists(true); setLoadingWorkspaceSize(false); return undefined; } setLoadingWorkspaceSize(true); fetchAgentWorkspaceSize(agentId) .then((result) => { if (cancelled) return; setWorkspaceSizeBytes(Number(result?.sizeBytes || 0)); setWorkspaceSizeExists(result?.exists !== false); }) .catch(() => { if (cancelled) return; setWorkspaceSizeBytes(null); setWorkspaceSizeExists(false); }) .finally(() => { if (cancelled) return; setLoadingWorkspaceSize(false); }); return () => { cancelled = true; }; }, [agent?.id, agent?.workspace]); return { loadingWorkspaceSize, workspaceSizeBytes, workspaceSizeExists, }; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-overview/workspace-card.js ================================================ import { h } from "preact"; import htm from "htm"; import { formatBytes } from "../../../lib/format.js"; import { useWorkspaceCard } from "./use-workspace-card.js"; const html = htm.bind(h); export const WorkspaceCard = ({ agent = {}, onOpenWorkspace = () => {}, }) => { const { loadingWorkspaceSize, workspaceSizeBytes, workspaceSizeExists, } = useWorkspaceCard({ agent }); return html`

Workspace

${agent.workspace ? html`
${loadingWorkspaceSize ? "Calculating size..." : workspaceSizeBytes != null ? formatBytes(workspaceSizeBytes) : workspaceSizeExists ? "Size unavailable" : "Workspace directory not found"}
` : html`

No workspace configured

`}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-pairing-section.js ================================================ import { h } from "preact"; import { useCallback, useEffect, useMemo, useRef, useState, } from "preact/hooks"; import htm from "htm"; import { Pairings } from "../pairings.js"; import { usePolling } from "../../hooks/usePolling.js"; import { approvePairing, fetchAgentBindings, fetchChannelAccounts, fetchPairings, rejectPairing, } from "../../lib/api.js"; import { showToast } from "../toast.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; const html = htm.bind(h); const toOwnedAccountKey = (channel, accountId) => { const normalizedChannel = String(channel || "").trim(); const normalizedAccountId = String(accountId || "").trim() || "default"; return normalizedChannel ? `${normalizedChannel}:${normalizedAccountId}` : ""; }; const announcePairingsChanged = (agentId) => { window.dispatchEvent( new CustomEvent("alphaclaw:pairings-changed", { detail: { agentId: String(agentId || "").trim() }, }), ); }; export const AgentPairingSection = ({ agent = {} }) => { const [bindings, setBindings] = useState([]); const [channels, setChannels] = useState([]); const [loadingBindings, setLoadingBindings] = useState(true); const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false); const pairingRefreshTimerRef = useRef(null); const pairingDelayedRefreshTimerRefs = useRef([]); const agentId = String(agent?.id || "").trim(); const isDefaultAgent = !!agent?.default; const { data: bindingsPayload, loading: bindingsLoading, refresh: refreshBindingsPayload, } = useCachedFetch( `/api/agents/${encodeURIComponent(String(agentId || ""))}/bindings`, () => fetchAgentBindings(agent.id), { enabled: Boolean(agentId), maxAgeMs: 30000, }, ); const { data: channelsPayload, loading: channelsLoading, refresh: refreshChannelsPayload, } = useCachedFetch("/api/channels/accounts", fetchChannelAccounts, { maxAgeMs: 30000, }); const loadBindings = useCallback(async () => { setLoadingBindings(true); try { const [nextBindingsPayload, nextChannelsPayload] = await Promise.all([ refreshBindingsPayload({ force: true }), refreshChannelsPayload({ force: true }), ]); setBindings( Array.isArray(nextBindingsPayload?.bindings) ? nextBindingsPayload.bindings : [], ); setChannels( Array.isArray(nextChannelsPayload?.channels) ? nextChannelsPayload.channels : [], ); } catch { setBindings([]); setChannels([]); } finally { setLoadingBindings(false); } }, [refreshBindingsPayload, refreshChannelsPayload]); useEffect(() => { setBindings( Array.isArray(bindingsPayload?.bindings) ? bindingsPayload.bindings : [], ); setChannels( Array.isArray(channelsPayload?.channels) ? channelsPayload.channels : [], ); setLoadingBindings(Boolean(bindingsLoading || channelsLoading)); }, [bindingsLoading, bindingsPayload, channelsLoading, channelsPayload]); useEffect(() => { const handleBindingsChanged = (event) => { const changedAgentId = String(event?.detail?.agentId || "").trim(); if (changedAgentId !== agentId) return; loadBindings(); }; window.addEventListener("alphaclaw:agent-bindings-changed", handleBindingsChanged); return () => { window.removeEventListener("alphaclaw:agent-bindings-changed", handleBindingsChanged); }; }, [agentId, loadBindings]); useEffect( () => () => { if (pairingRefreshTimerRef.current) { clearTimeout(pairingRefreshTimerRef.current); } for (const timerId of pairingDelayedRefreshTimerRefs.current) { clearTimeout(timerId); } pairingDelayedRefreshTimerRefs.current = []; }, [], ); const ownedAccounts = useMemo( () => { const ownedAccountMap = new Map(); for (const binding of bindings) { const channelId = String(binding?.match?.channel || "").trim(); if (!channelId) continue; const accountId = String(binding?.match?.accountId || "").trim() || "default"; const key = toOwnedAccountKey(channelId, accountId); if (!key) continue; ownedAccountMap.set(key, { channel: channelId, accountId }); } for (const channel of channels) { const channelId = String(channel?.channel || "").trim(); const accounts = Array.isArray(channel?.accounts) ? channel.accounts : []; const defaultAccount = accounts.find( (entry) => String(entry?.id || "").trim() === "default", ); if ( isDefaultAgent && channelId && defaultAccount && !String(defaultAccount?.boundAgentId || "").trim() ) { const key = toOwnedAccountKey(channelId, "default"); ownedAccountMap.set(key, { channel: channelId, accountId: "default" }); } } return Array.from(ownedAccountMap.values()); }, [bindings, channels, isDefaultAgent], ); const boundChannels = useMemo( () => Array.from(new Set(ownedAccounts.map((entry) => entry.channel))).filter(Boolean), [ownedAccounts], ); const ownedAccountKeySet = useMemo( () => new Set( ownedAccounts .map((entry) => toOwnedAccountKey(entry.channel, entry.accountId)) .filter(Boolean), ), [ownedAccounts], ); const accountNameMap = useMemo(() => { const nextMap = new Map(); for (const channel of channels) { const channelId = String(channel?.channel || "").trim(); const accounts = Array.isArray(channel?.accounts) ? channel.accounts : []; for (const account of accounts) { const accountId = String(account?.id || "").trim() || "default"; const key = toOwnedAccountKey(channelId, accountId); if (!key) continue; const configuredName = String(account?.name || "").trim(); nextMap.set(key, configuredName || accountId); } } return nextMap; }, [channels]); const ownedChannelsStatus = useMemo(() => { const nextStatus = {}; for (const entry of ownedAccounts) { const channelId = String(entry?.channel || "").trim(); if (!channelId) continue; const key = toOwnedAccountKey(channelId, entry?.accountId); const account = channels .find((channel) => String(channel?.channel || "").trim() === channelId) ?.accounts?.find( (accountEntry) => (String(accountEntry?.id || "").trim() || "default") === (String(entry?.accountId || "").trim() || "default"), ); const status = String(account?.status || "").trim() || "configured"; if (!nextStatus[channelId] || status !== "paired") { nextStatus[channelId] = { status: status === "paired" ? "paired" : "configured", accountName: accountNameMap.get(key) || "", }; } } return nextStatus; }, [accountNameMap, channels, ownedAccounts]); const hasUnpaired = useMemo( () => Object.values(ownedChannelsStatus).some( (entry) => String(entry?.status || "").trim() !== "paired", ), [ownedChannelsStatus], ); const pairingsPoll = usePolling( async () => { const data = await fetchPairings(); const pending = Array.isArray(data?.pending) ? data.pending : []; return pending .filter((entry) => ownedAccountKeySet.has( toOwnedAccountKey( String(entry?.channel || "").trim(), String(entry?.accountId || "").trim() || "default", ), ), ) .map((entry) => { const key = toOwnedAccountKey(entry?.channel, entry?.accountId); return { ...entry, accountName: accountNameMap.get(key) || "", }; }); }, 3000, { enabled: ownedAccounts.length > 0, cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`, dedupeInFlight: true, }, ); const pending = pairingsPoll.data || []; const showPairings = hasUnpaired || pending.length > 0 || pairingStatusRefreshing; const refreshAfterPairingAction = useCallback(() => { setPairingStatusRefreshing(true); if (pairingRefreshTimerRef.current) { clearTimeout(pairingRefreshTimerRef.current); } pairingRefreshTimerRef.current = setTimeout(() => { setPairingStatusRefreshing(false); pairingRefreshTimerRef.current = null; }, 2800); for (const timerId of pairingDelayedRefreshTimerRefs.current) { clearTimeout(timerId); } pairingDelayedRefreshTimerRefs.current = []; const refresh = () => { pairingsPoll.refresh({ force: true }); loadBindings(); announcePairingsChanged(agentId); }; refresh(); pairingDelayedRefreshTimerRefs.current.push(setTimeout(refresh, 500)); pairingDelayedRefreshTimerRefs.current.push(setTimeout(refresh, 2000)); }, [agentId, loadBindings, pairingsPoll]); const handleApprove = async (id, channel, accountId = "") => { try { const result = await approvePairing(id, channel, accountId); if (!result.ok) throw new Error(result.error || "Could not approve pairing"); refreshAfterPairingAction(); } catch (err) { showToast(err.message || "Could not approve pairing", "error"); throw err; } }; const handleReject = async (id, channel, accountId = "") => { try { await rejectPairing(id, channel, accountId); refreshAfterPairingAction(); } catch (err) { showToast(err.message || "Could not reject pairing", "error"); throw err; } }; if (loadingBindings) { return html`

Pairing

Loading pairing status...

`; } if (!showPairings) return null; return html` <${Pairings} pending=${pending} channels=${ownedChannelsStatus} visible=${showPairings} pollingInFlight=${pairingsPoll.isPolling} statusRefreshing=${pairingStatusRefreshing} onApprove=${handleApprove} onReject=${handleReject} /> `; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-tools/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ToggleSwitch } from "../../toggle-switch.js"; import { InfoTooltip } from "../../info-tooltip.js"; import { SegmentedControl } from "../../segmented-control.js"; import { kSections, kToolProfiles, kProfileLabels } from "./tool-catalog.js"; const html = htm.bind(h); const kProfileDescriptions = { minimal: "Only session status — grant specific tools with alsoAllow", messaging: "Session access and messaging — ideal for notification agents", coding: "File I/O, shell, memory, sessions, cron, and image generation", full: "All tools enabled, no restrictions", }; const kProfileOptions = kToolProfiles.map((p) => ({ label: kProfileLabels[p], value: p, title: kProfileDescriptions[p], })); const ToolRow = ({ tool, onToggle }) => html`
${tool.label} ${tool.help ? html`<${InfoTooltip} text=${tool.help} widthClass="w-72" />` : null}
${tool.id}
<${ToggleSwitch} checked=${tool.enabled} onChange=${(checked) => onToggle(tool.id, checked)} label=${null} />
`; const ToolSection = ({ section, toolStates, onToggle }) => { const sectionTools = toolStates.filter((t) => t.section === section.id); if (!sectionTools.length) return null; return html`

${section.label}

${sectionTools.map( (tool) => html`<${ToolRow} key=${tool.id} tool=${tool} onToggle=${onToggle} />`, )}
`; }; export const AgentToolsPanel = ({ agent = {}, tools = {} }) => { const { profile, toolStates, setProfile, toggleTool } = tools; const enabledTotal = (toolStates || []).filter((t) => t.enabled).length; const totalTools = (toolStates || []).length; return html`

Preset

${enabledTotal}/${totalTools} tools enabled
<${SegmentedControl} options=${kProfileOptions} value=${profile} onChange=${setProfile} fullWidth className="ac-segmented-control-dark" />
${kSections.map( (section) => html`
<${ToolSection} key=${section.id} section=${section} toolStates=${toolStates || []} onToggle=${toggleTool} />
`, )}
`; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-tools/tool-catalog.js ================================================ /** * Static tool catalog mirroring OpenClaw's tool-catalog.ts. * Grouped and labeled for the AlphaClaw Setup UI. */ export const kToolProfiles = ["minimal", "messaging", "coding", "full"]; export const kProfileLabels = { minimal: "Minimal", messaging: "Messaging", coding: "Coding", full: "Full", }; const kTools = [ { id: "read", label: "Read files", profiles: ["coding"], section: "filesystem", }, { id: "edit", label: "Edit files", profiles: ["coding"], section: "filesystem", }, { id: "write", label: "Write files", profiles: ["coding"], section: "filesystem", }, { id: "apply_patch", label: "Apply patches", help: "Make targeted patch edits, mainly for OpenAI-compatible patch workflows.", profiles: ["coding"], section: "filesystem", }, { id: "exec", label: "Run commands", help: "Execute shell commands inside the agent environment.", profiles: ["coding"], section: "execution", }, { id: "process", label: "Manage processes", help: "Inspect and control long-running background processes.", profiles: ["coding"], section: "execution", }, { id: "message", label: "Send messages", help: "Send outbound messages through configured messaging channels.", profiles: ["messaging"], section: "communication", }, { id: "tts", label: "Text-to-speech", help: "Convert text responses into generated speech audio.", profiles: [], section: "communication", }, { id: "browser", label: "Control browser", help: "Drive a browser for page navigation and interactive web tasks.", profiles: [], section: "web", }, { id: "web_search", label: "Search the web", help: "Run web searches to discover external information.", profiles: [], section: "web", }, { id: "web_fetch", label: "Fetch URLs", help: "Fetch and read webpage content from a specific URL.", profiles: [], section: "web", }, { id: "memory_search", label: "Semantic search", help: "Search memory semantically to find related notes and prior context.", profiles: ["coding"], section: "memory", }, { id: "memory_get", label: "Read memories", help: "Read stored memory files and saved context entries.", profiles: ["coding"], section: "memory", }, { id: "agents_list", label: "List agents", help: "List known agent IDs that can be targeted in multi-agent flows.", profiles: [], section: "multiagent", }, { id: "sessions_spawn", label: "Spawn sessions", help: "Start a new background session/run; this is the base primitive used by sub-agent workflows.", profiles: ["coding"], section: "multiagent", }, { id: "sessions_send", label: "Send to session", help: "Send messages or tasks into an existing running session.", profiles: ["coding", "messaging"], section: "multiagent", }, { id: "sessions_list", label: "List sessions", help: "List active or recent sessions available to the agent.", profiles: ["coding", "messaging"], section: "multiagent", }, { id: "sessions_history", label: "Session history", help: "Read the transcript and prior exchanges from a session.", profiles: ["coding", "messaging"], section: "multiagent", }, { id: "session_status", label: "Session status", help: "Check whether a session is running and inspect runtime health/state.", profiles: ["minimal", "coding", "messaging"], section: "multiagent", }, { id: "subagents", label: "Sub-agents", help: "Launch specialized delegated agents (higher-level orchestration built on session spawning).", profiles: ["coding"], section: "multiagent", }, { id: "cron", label: "Scheduled jobs", help: "Create and manage scheduled automation jobs.", profiles: ["coding"], section: "scheduling", }, { id: "gateway", label: "Gateway control", help: "Inspect and control the running Gateway service (status, health, and control actions like restart).", profiles: [], section: "scheduling", }, { id: "image", label: "Generate images", help: "Generate or analyze images with image-capable model tools.", profiles: ["coding"], section: "creative", }, { id: "canvas", label: "Visual canvas", help: "Control the Canvas panel (present, navigate, eval, snapshot). Primarily a macOS app capability when a canvas-capable node is connected.", profiles: [], section: "creative", }, { id: "nodes", label: "Node workflows", help: "Use paired device/node capabilities (for example canvas, camera, notifications, and system actions).", profiles: [], section: "creative", }, ]; export const kSections = [ { id: "filesystem", label: "Filesystem", description: "Read, edit, and write files", }, { id: "execution", label: "Execution", description: "Run shell commands and scripts", }, { id: "communication", label: "Communication", description: "Send messages across Telegram, Slack, Discord", }, { id: "web", label: "Web & Browser", description: "Browse pages, search the web, fetch URLs", }, { id: "memory", label: "Memory", description: "Semantic search and retrieval across the agent's stored knowledge", }, { id: "multiagent", label: "Multi-Agent", description: "List agents, spawn sessions, send messages between agents. Orchestrate sub-agents.", }, { id: "scheduling", label: "Scheduling", description: "Create and manage scheduled jobs", }, { id: "creative", label: "Creative", description: "Generate images, visual canvas, node-based workflows", }, ]; export const getToolsForSection = (sectionId) => kTools.filter((t) => t.section === sectionId); export const getAllToolIds = () => kTools.map((t) => t.id); export const getProfileToolIds = (profileId) => { if (profileId === "full") return kTools.map((t) => t.id); return kTools.filter((t) => t.profiles.includes(profileId)).map((t) => t.id); }; /** * Given a profile + alsoAllow + deny, resolve whether each tool is enabled. */ export const resolveToolStates = ({ profile = "full", alsoAllow = [], deny = [], }) => { const profileTools = new Set(getProfileToolIds(profile)); const alsoAllowSet = new Set(alsoAllow); const denySet = new Set(deny); return kTools.map((tool) => { const inProfile = profileTools.has(tool.id); const isDenied = denySet.has(tool.id); const isAlsoAllowed = alsoAllowSet.has(tool.id); const enabled = isDenied ? false : inProfile || isAlsoAllowed; return { ...tool, enabled, inProfile, isDenied, isAlsoAllowed }; }); }; /** * Derive the minimal tools config from the resolved tool states * relative to the selected profile. */ export const deriveToolsConfig = ({ profile, toolStates }) => { const profileTools = new Set(getProfileToolIds(profile)); const alsoAllow = []; const deny = []; for (const tool of toolStates) { const inProfile = profileTools.has(tool.id); if (tool.enabled && !inProfile) { alsoAllow.push(tool.id); } else if (!tool.enabled && inProfile) { deny.push(tool.id); } } const config = { profile }; if (alsoAllow.length) config.alsoAllow = alsoAllow; if (deny.length) config.deny = deny; return config; }; ================================================ FILE: lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js ================================================ import { useState, useCallback, useMemo, useEffect, useRef } from "preact/hooks"; import { resolveToolStates, deriveToolsConfig, getProfileToolIds, } from "./tool-catalog.js"; const buildOverridesMap = (alsoAllow = [], deny = []) => { const map = {}; for (const id of alsoAllow) map[id] = true; for (const id of deny) map[id] = false; return map; }; const normalizeToolsConfig = ({ profile = "full", alsoAllow = [], deny = [], } = {}) => ({ profile: String(profile || "full"), alsoAllow: [...(Array.isArray(alsoAllow) ? alsoAllow : [])] .map(String) .filter(Boolean) .sort(), deny: [...(Array.isArray(deny) ? deny : [])] .map(String) .filter(Boolean) .sort(), }); /** * Manages local tool-toggle state derived from an agent's tools config. * Returns the current resolved states plus actions for profile/tool changes. */ export const useAgentTools = ({ agent = {} } = {}) => { const agentTools = agent.tools || {}; const initialConfig = normalizeToolsConfig(agentTools); const initialProfile = initialConfig.profile; const initialAlsoAllow = initialConfig.alsoAllow; const initialDeny = initialConfig.deny; const [profile, setProfileRaw] = useState(initialProfile); const [overrides, setOverrides] = useState(() => buildOverridesMap(initialAlsoAllow, initialDeny), ); const [savedConfig, setSavedConfig] = useState(initialConfig); const agentToolsKey = JSON.stringify([agent.id, agentTools]); const prevKeyRef = useRef(agentToolsKey); useEffect(() => { if (prevKeyRef.current !== agentToolsKey) { prevKeyRef.current = agentToolsKey; setProfileRaw(initialProfile); setOverrides(buildOverridesMap(initialAlsoAllow, initialDeny)); setSavedConfig(initialConfig); } }, [agentToolsKey, initialProfile, initialAlsoAllow, initialDeny, initialConfig]); const toolStates = useMemo(() => { const profileSet = new Set(getProfileToolIds(profile)); const alsoAllow = []; const deny = []; for (const [id, enabled] of Object.entries(overrides)) { if (enabled && !profileSet.has(id)) alsoAllow.push(id); else if (!enabled && profileSet.has(id)) deny.push(id); } return resolveToolStates({ profile, alsoAllow, deny }); }, [profile, overrides]); const toolsConfig = useMemo( () => deriveToolsConfig({ profile, toolStates }), [profile, toolStates], ); const dirty = useMemo(() => { const next = normalizeToolsConfig(toolsConfig); return JSON.stringify(savedConfig) !== JSON.stringify(next); }, [savedConfig, toolsConfig]); const setProfile = useCallback((nextProfile) => { setProfileRaw(nextProfile); setOverrides({}); }, []); const toggleTool = useCallback( (toolId, enabled) => { setOverrides((prev) => { const next = { ...prev }; const profileSet = new Set(getProfileToolIds(profile)); const isDefault = profileSet.has(toolId) === enabled; if (isDefault) { delete next[toolId]; } else { next[toolId] = enabled; } return next; }); }, [profile], ); const reset = useCallback(() => { setProfileRaw(savedConfig.profile); const map = {}; for (const id of savedConfig.alsoAllow) map[id] = true; for (const id of savedConfig.deny) map[id] = false; setOverrides(map); }, [savedConfig]); const markSaved = useCallback((nextConfig = {}) => { const normalized = normalizeToolsConfig(nextConfig); setSavedConfig(normalized); setProfileRaw(normalized.profile); setOverrides(buildOverridesMap(normalized.alsoAllow, normalized.deny)); }, []); return { profile, toolStates, toolsConfig, dirty, setProfile, toggleTool, reset, markSaved, }; }; ================================================ FILE: lib/public/js/components/agents-tab/create-agent-modal.js ================================================ import { h } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { CloseIcon } from "../icons.js"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; const html = htm.bind(h); const kAgentIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const kWorkspaceFolderPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const slugifyAgentId = (value) => String(value || "") .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); export const CreateAgentModal = ({ visible = false, loading = false, onClose = () => {}, onSubmit = () => {}, }) => { const [displayName, setDisplayName] = useState(""); const [agentId, setAgentId] = useState(""); const [workspaceSuffix, setWorkspaceSuffix] = useState(""); const [error, setError] = useState(""); const [idEditedManually, setIdEditedManually] = useState(false); const [workspaceEditedManually, setWorkspaceEditedManually] = useState(false); useEffect(() => { if (!visible) return; setDisplayName(""); setAgentId(""); setWorkspaceSuffix(""); setError(""); setIdEditedManually(false); setWorkspaceEditedManually(false); }, [visible]); useEffect(() => { if (idEditedManually) return; const derivedId = slugifyAgentId(displayName); setAgentId(derivedId); }, [displayName, idEditedManually]); useEffect(() => { if (workspaceEditedManually) return; const trimmedId = String(agentId || "").trim(); if (!trimmedId) { setWorkspaceSuffix(""); return; } setWorkspaceSuffix(trimmedId); }, [agentId, workspaceEditedManually]); const workspaceFolder = useMemo( () => `workspace-${String(workspaceSuffix || "").trim()}`, [workspaceSuffix], ); const canSubmit = String(displayName || "").trim().length > 0 && kAgentIdPattern.test(String(agentId || "").trim()) && kWorkspaceFolderPattern.test(String(workspaceSuffix || "").trim()); if (!visible) return null; const submit = async () => { const nextDisplayName = String(displayName || "").trim(); const nextAgentId = String(agentId || "").trim(); const nextWorkspaceSuffix = String(workspaceSuffix || "").trim(); const nextWorkspaceFolder = `workspace-${nextWorkspaceSuffix}`; if (!nextDisplayName) { setError("Display name is required"); return; } if (!kAgentIdPattern.test(nextAgentId)) { setError("Agent ID must be lowercase letters, numbers, and hyphens"); return; } if (!kWorkspaceFolderPattern.test(nextWorkspaceSuffix)) { setError("Workspace folder must be lowercase letters, numbers, and hyphens"); return; } setError(""); const payload = { name: nextDisplayName, id: nextAgentId, workspaceFolder: nextWorkspaceFolder, }; await onSubmit(payload); }; return html` <${ModalShell} visible=${visible} onClose=${onClose} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > <${PageHeader} title="Add Agent" actions=${html` `} />
${error ? html`

${error}

` : null}
<${ActionButton} onClick=${onClose} disabled=${loading} loading=${false} tone="secondary" size="md" idleLabel="Cancel" /> <${ActionButton} onClick=${submit} disabled=${loading || !canSubmit} loading=${loading} tone="primary" size="md" idleLabel="Create Agent" loadingLabel="Creating..." />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/create-channel-modal.js ================================================ import { h } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { CloseIcon, FileCopyLineIcon } from "../icons.js"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; import { SecretInput } from "../secret-input.js"; import { fetchChannelAccountToken } from "../../lib/api.js"; import { copyTextToClipboard } from "../../lib/clipboard.js"; import { isSingleAccountChannelProvider } from "../../lib/channel-provider-availability.js"; import { ALL_CHANNELS, getChannelMeta } from "../channels.js"; import { showToast } from "../toast.js"; const html = htm.bind(h); const kChannelEnvKeys = { telegram: "TELEGRAM_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", slack: "SLACK_BOT_TOKEN", whatsapp: "WHATSAPP_OWNER_NUMBER", }; const kChannelExtraEnvKeys = { slack: "SLACK_APP_TOKEN", }; const kSlackBotScopes = [ "app_mentions:read", "channels:history", "channels:read", "chat:write", "commands", "emoji:read", "files:read", "files:write", "groups:read", "groups:history", "im:history", "im:read", "im:write", "mpim:history", "mpim:read", "mpim:write", "pins:read", "pins:write", "reactions:read", "reactions:write", "users:read", ]; const kSlackBotEvents = [ "app_mention", "message.channels", "message.groups", "message.im", "message.mpim", "reaction_added", "reaction_removed", ]; const kSlackInstructionsLink = "https://docs.openclaw.ai/channels/slack"; const slugifyChannelAccountId = (value) => String(value || "") .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const deriveChannelEnvKey = ({ provider, accountId }) => { const baseKey = kChannelEnvKeys[String(provider || "").trim()] || ""; const normalizedAccountId = String(accountId || "").trim(); if (!baseKey) return ""; if (!normalizedAccountId || normalizedAccountId === "default") return baseKey; return `${baseKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; }; const deriveChannelExtraEnvKey = ({ provider, accountId, index = 0 }) => { const baseKeys = [kChannelExtraEnvKeys[String(provider || "").trim()]].filter( Boolean, ); const baseKey = String(baseKeys[index] || "").trim(); const normalizedAccountId = String(accountId || "").trim(); if (!baseKey) return ""; if (!normalizedAccountId || normalizedAccountId === "default") return baseKey; return `${baseKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; }; const isMaskedTokenValue = (value) => /^\*+$/.test(String(value || "").trim()); const buildSlackManifest = (appName = "AlphaClaw") => JSON.stringify( { _metadata: { major_version: 1, }, display_information: { name: String(appName || "").trim() || "AlphaClaw", description: "Slack connector for AlphaClaw", }, features: { bot_user: { display_name: String(appName || "").trim() || "AlphaClaw", always_online: false, }, app_home: { messages_tab_enabled: true, messages_tab_read_only_enabled: false, }, }, oauth_config: { scopes: { bot: kSlackBotScopes, }, }, settings: { event_subscriptions: { bot_events: kSlackBotEvents, }, org_deploy_enabled: false, socket_mode_enabled: true, is_hosted: false, token_rotation_enabled: false, }, }, null, 2, ); const copyAndToast = async (value, label = "text") => { const copied = await copyTextToClipboard(value); if (copied) { showToast("Copied to clipboard", "success"); return; } showToast(`Could not copy ${label}`, "error"); }; export const CreateChannelModal = ({ visible = false, loading = false, createLoadingLabel = "Creating...", agents = [], existingChannels = [], mode = "create", account = null, initialAgentId = "", initialProvider = "", onClose = () => {}, onSubmit = async () => {}, }) => { const isEditMode = mode === "edit"; const [provider, setProvider] = useState("telegram"); const [name, setName] = useState(""); const [token, setToken] = useState(""); const [initialToken, setInitialToken] = useState(""); const [appToken, setAppToken] = useState(""); const [agentId, setAgentId] = useState(""); const [error, setError] = useState(""); const [nameEditedManually, setNameEditedManually] = useState(false); const [loadingToken, setLoadingToken] = useState(false); useEffect(() => { if (!visible) return; const nextProvider = isEditMode ? String(account?.provider || "").trim() || "telegram" : ALL_CHANNELS.includes(initialProvider) ? initialProvider : ALL_CHANNELS[0] || "telegram"; const providerLabel = getChannelMeta(nextProvider).label || "Channel"; const nextSelectedChannel = existingChannels.find( (entry) => String(entry?.channel || "").trim() === String(nextProvider || "").trim(), ) || null; const nextProviderHasAccounts = Array.isArray(nextSelectedChannel?.accounts) && nextSelectedChannel.accounts.length > 0; const nextName = isEditMode ? String(account?.name || "").trim() || providerLabel : nextProviderHasAccounts ? "" : providerLabel; const nextAgentId = isEditMode ? String(account?.ownerAgentId || "").trim() || String(initialAgentId || "").trim() || String(agents[0]?.id || "").trim() : String(initialAgentId || "").trim() || String(agents[0]?.id || "").trim(); setProvider(nextProvider); setName(nextName); const nextToken = isEditMode ? (() => { const raw = String(account?.token || "").trim(); return isMaskedTokenValue(raw) ? "" : raw; })() : ""; setToken(nextToken); setInitialToken(nextToken); setAppToken(""); setAgentId(nextAgentId); setError(""); setNameEditedManually(isEditMode); }, [ visible, initialAgentId, initialProvider, agents, existingChannels, isEditMode, account, ]); const selectedChannel = useMemo( () => existingChannels.find( (entry) => String(entry?.channel || "").trim() === String(provider || "").trim(), ) || null, [existingChannels, provider], ); const providerHasAccounts = useMemo( () => Array.isArray(selectedChannel?.accounts) && selectedChannel.accounts.length > 0, [selectedChannel], ); useEffect(() => { if (nameEditedManually) return; const providerLabel = getChannelMeta(provider).label || "Channel"; if (!isEditMode && providerHasAccounts) { setName(""); return; } setName(providerLabel); }, [provider, providerHasAccounts, nameEditedManually, isEditMode]); const normalizedProvider = String(provider || "").trim(); const isSingleAccountProvider = isSingleAccountChannelProvider(provider); const needsAppToken = normalizedProvider === "slack"; const isWhatsApp = normalizedProvider === "whatsapp"; const accountId = useMemo(() => { if (isEditMode) { return String(account?.id || "").trim() || "default"; } if (isSingleAccountProvider) return "default"; if (!providerHasAccounts) return "default"; return slugifyChannelAccountId(name); }, [name, providerHasAccounts, isEditMode, account, isSingleAccountProvider]); const envKey = useMemo( () => deriveChannelEnvKey({ provider, accountId }), [provider, accountId], ); const extraEnvKey = useMemo( () => deriveChannelExtraEnvKey({ provider, accountId, }), [provider, accountId], ); const slackManifestName = useMemo(() => { const normalizedName = String(name || "").trim(); if (!normalizedName) return "AlphaClaw"; if (normalizedName.toLowerCase() === "slack") return "AlphaClaw"; return normalizedName; }, [name]); const slackManifest = useMemo( () => buildSlackManifest(slackManifestName), [slackManifestName], ); const accountExists = useMemo( () => Array.isArray(selectedChannel?.accounts) && selectedChannel.accounts.some( (entry) => String(entry?.id || "").trim() === String(accountId || "").trim(), ), [selectedChannel, accountId], ); useEffect(() => { if (!visible || !isEditMode) return; let cancelled = false; const loadToken = async () => { setLoadingToken(true); try { const result = await fetchChannelAccountToken({ provider, accountId, }); if (cancelled) return; const nextToken = String(result?.token || ""); const nextAppToken = String(result?.appToken || ""); setToken(nextToken); setInitialToken(nextToken); setAppToken(nextAppToken); } catch { // Keep existing fallback value. } finally { if (!cancelled) { setLoadingToken(false); } } }; loadToken(); return () => { cancelled = true; }; }, [visible, isEditMode, provider, accountId]); const canSubmit = !!String(provider || "").trim() && !!String(name || "").trim() && !!String(accountId || "").trim() && !!String(agentId || "").trim() && (isEditMode || !!String(token || "").trim()) && (isEditMode || !needsAppToken || !!String(appToken || "").trim()) && (isEditMode || !accountExists) && !loadingToken; if (!visible) return null; const handleSubmit = async () => { if (!String(name || "").trim()) { setError("Name is required"); return; } if (!String(accountId || "").trim()) { setError("Channel id could not be derived from the name"); return; } if (!isEditMode && !String(token || "").trim()) { setError("Token is required"); return; } if (!isEditMode && needsAppToken && !String(appToken || "").trim()) { setError("App Token is required for Slack"); return; } if (!String(agentId || "").trim()) { setError("Agent is required"); return; } if (!isEditMode && accountExists) { setError("That channel id is already configured for this provider"); return; } setError(""); const trimmedToken = String(token || "").trim(); const tokenWasUpdated = trimmedToken && trimmedToken !== String(initialToken || "").trim(); const trimmedAppToken = String(appToken || "").trim(); await onSubmit({ provider, name: String(name || "").trim(), accountId, agentId, ...(tokenWasUpdated ? { token: trimmedToken } : {}), ...(needsAppToken && trimmedAppToken ? { appToken: trimmedAppToken } : {}), }); }; return html` <${ModalShell} visible=${visible} onClose=${onClose} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full max-h-[calc(100vh-2rem)] overflow-y-auto space-y-4" > <${PageHeader} title=${ isEditMode ? "Edit Channel" : `Add ${getChannelMeta(provider).label || "Channel"} Channel` } actions=${html` `} />
${ needsAppToken ? html` ` : null } ${ needsAppToken ? html`
Create app from manifest (recommended)

${slackManifestName} App Manifest

${slackManifest}
  1. In Slack, click ${" "} Create app from manifest ${" "} and paste this manifest.
  2. Open ${" "} Basic Information ${" "} and create an ${" "} App-Level Token ${" "} with connections:write.
  3. Open ${" "} OAuth & Permissions ${" "} and use ${" "} Install to Workspace ${" "} or ${" "} Reinstall to Workspace ${" "} so Slack issues a bot token.
  4. In ${" "} OAuth & Permissions ${" "} copy the ${" "} Bot User OAuth Token ${" "} ( xoxb-... ).
  5. Paste the generated ${" "} xoxb-... ${" "} and ${" "} xapp-... ${" "} tokens here.
Manual setup instructions

Use this if you want to configure the Slack app by hand instead of importing a manifest.

  1. In Slack app settings, turn on ${" "} Socket Mode.
  2. In ${" "} App Home, enable Allow users to send Slash commands and messages from the messages tab .
  3. In ${" "} Event Subscriptions, toggle on Subscribe to bot events ${" "} and add message.im.
  4. In ${" "} OAuth & Permissions, add the bot scopes: ${kSlackBotScopes.join(", ")}
  5. In ${" "} Basic Information, create an App Token (xapp-...) with connections:write.
  6. Back in ${" "} OAuth & Permissions, install or reinstall the app, then copy the ${" "} Bot User OAuth Token ${" "} ( xoxb-... ).
Open full Slack setup guide
` : null } ${ !isEditMode && accountExists ? html`

${isSingleAccountProvider ? `${getChannelMeta(provider).label} already has a configured channel account.` : `A ${getChannelMeta(provider).label} account with this id already exists.`}

` : null } ${error ? html`

${error}

` : null}
<${ActionButton} onClick=${onClose} disabled=${loading} loading=${false} tone="secondary" size="md" idleLabel="Cancel" /> <${ActionButton} onClick=${handleSubmit} disabled=${loading || !canSubmit} loading=${loading} tone="primary" size="md" idleLabel=${isEditMode ? "Save Changes" : "Create Channel"} loadingLabel=${isEditMode ? "Saving..." : createLoadingLabel} />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/delete-agent-dialog.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { ConfirmDialog } from "../confirm-dialog.js"; import { ToggleSwitch } from "../toggle-switch.js"; const html = htm.bind(h); export const DeleteAgentDialog = ({ visible = false, loading = false, agent = null, onCancel = () => {}, onConfirm = () => {}, }) => { const [keepWorkspace, setKeepWorkspace] = useState(true); useEffect(() => { if (!visible) return; setKeepWorkspace(true); }, [visible]); return html` <${ConfirmDialog} visible=${visible} title="Delete agent" message=${`Delete "${String(agent?.name || agent?.id || "agent")}"?`} details=${html`
<${ToggleSwitch} checked=${keepWorkspace} disabled=${loading} onChange=${setKeepWorkspace} label="Keep workspace files" />
`} confirmLabel="Delete agent" confirmLoadingLabel="Deleting..." confirmTone="warning" confirmLoading=${loading} onCancel=${onCancel} onConfirm=${() => onConfirm({ id: String(agent?.id || "").trim(), keepWorkspace, })} /> `; }; ================================================ FILE: lib/public/js/components/agents-tab/edit-agent-modal.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { CloseIcon } from "../icons.js"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; const html = htm.bind(h); export const EditAgentModal = ({ visible = false, loading = false, agent = null, onClose = () => {}, onSubmit = () => {}, }) => { const [name, setName] = useState(""); const [error, setError] = useState(""); useEffect(() => { if (!visible) return; setName(String(agent?.name || "")); setError(""); }, [visible, agent]); if (!visible) return null; const submit = async () => { const nextName = String(name || "").trim(); if (!nextName) { setError("Display name is required"); return; } setError(""); await onSubmit({ id: String(agent?.id || "").trim(), patch: { name: nextName, }, }); }; return html` <${ModalShell} visible=${visible} onClose=${onClose} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > <${PageHeader} title="Edit Agent" actions=${html` `} />
${error ? html`

${error}

` : null}
<${ActionButton} onClick=${onClose} disabled=${loading} loading=${false} tone="secondary" size="sm" idleLabel="Cancel" /> <${ActionButton} onClick=${submit} disabled=${loading} loading=${loading} tone="primary" size="sm" idleLabel="Save" loadingLabel="Saving..." />
`; }; ================================================ FILE: lib/public/js/components/agents-tab/index.js ================================================ import { h } from "preact"; import { useState, useEffect, useCallback } from "preact/hooks"; import htm from "htm"; import { LoadingSpinner } from "../loading-spinner.js"; import { showToast } from "../toast.js"; import { AgentDetailPanel } from "./agent-detail-panel.js"; import { CreateAgentModal } from "./create-agent-modal.js"; import { DeleteAgentDialog } from "./delete-agent-dialog.js"; import { EditAgentModal } from "./edit-agent-modal.js"; const html = htm.bind(h); const resolveWorkspaceBrowsePath = (workspacePath) => { const rawPath = String(workspacePath || "").trim(); if (!rawPath) return ""; const openclawMatch = rawPath.match(/[\\/]\.openclaw[\\/](.+)$/); if (openclawMatch?.[1]) { return String(openclawMatch[1]).replace(/\\/g, "/"); } const segments = rawPath.split(/[\\/]/).filter(Boolean); return segments[segments.length - 1] || ""; }; export const AgentsTab = ({ agents = [], loading = false, saving = false, agentsActions = {}, selectedAgentId = "", activeTab = "overview", onSelectAgent = () => {}, onSelectTab = () => {}, onNavigateToBrowseFile = () => {}, onSetLocation = () => {}, }) => { const { create, remove, setDefault, update } = agentsActions; const [createModalVisible, setCreateModalVisible] = useState(false); const [editingAgent, setEditingAgent] = useState(null); const [deletingAgent, setDeletingAgent] = useState(null); useEffect(() => { const handleCreateEvent = () => setCreateModalVisible(true); window.addEventListener("alphaclaw:create-agent", handleCreateEvent); return () => window.removeEventListener("alphaclaw:create-agent", handleCreateEvent); }, []); const selectedAgent = agents.find((a) => a.id === selectedAgentId) || null; const handleCreate = async ({ id, name, workspaceFolder }) => { try { const newAgent = await create({ id, name, workspaceFolder }); setCreateModalVisible(false); onSelectAgent(newAgent.id); showToast("Agent created", "success"); } catch (error) { showToast(error.message || "Could not create agent", "error"); } }; const handleSetDefault = async (id) => { try { await setDefault(id); showToast("Default agent updated", "success"); } catch (error) { showToast(error.message || "Could not set default agent", "error"); } }; const handleUpdateAgent = async (id, patch, successMessage = "Agent updated") => { try { const nextAgent = await update(id, patch); showToast(successMessage, "success"); return nextAgent; } catch (error) { showToast(error.message || "Could not update agent", "error"); throw error; } }; const handleEdit = async ({ id, patch }) => { try { await handleUpdateAgent(id, patch); setEditingAgent(null); } catch (error) { return; } }; const handleDelete = async ({ id, keepWorkspace }) => { try { await remove(id, { keepWorkspace }); setDeletingAgent(null); showToast("Agent deleted", "success"); } catch (error) { showToast(error.message || "Could not delete agent", "error"); } }; const handleOpenWorkspace = (workspacePath) => { const browsePath = resolveWorkspaceBrowsePath(workspacePath); if (!browsePath) return; onNavigateToBrowseFile(browsePath, { view: "edit", directory: true }); }; if (loading) { return html`
<${LoadingSpinner} className="h-5 w-5" />
`; } return html` <${AgentDetailPanel} agent=${selectedAgent} agents=${agents} activeTab=${activeTab} saving=${saving} onUpdateAgent=${handleUpdateAgent} onSetLocation=${onSetLocation} onSelectTab=${onSelectTab} onEdit=${setEditingAgent} onDelete=${setDeletingAgent} onSetDefault=${handleSetDefault} onOpenWorkspace=${handleOpenWorkspace} /> <${CreateAgentModal} visible=${createModalVisible} loading=${saving} onClose=${() => setCreateModalVisible(false)} onSubmit=${handleCreate} /> <${EditAgentModal} visible=${!!editingAgent} loading=${saving} agent=${editingAgent} onClose=${() => setEditingAgent(null)} onSubmit=${handleEdit} /> <${DeleteAgentDialog} visible=${!!deletingAgent} loading=${saving} agent=${deletingAgent} onCancel=${() => setDeletingAgent(null)} onConfirm=${handleDelete} /> `; }; ================================================ FILE: lib/public/js/components/agents-tab/use-agents.js ================================================ import { useCallback, useEffect, useState } from "preact/hooks"; import { createAgent, deleteAgent, fetchAgents, setDefaultAgent, updateAgent, } from "../../lib/api.js"; export const useAgents = () => { const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const loadAgents = useCallback(async () => { setLoading(true); try { const payload = await fetchAgents(); setAgents(Array.isArray(payload?.agents) ? payload.agents : []); } finally { setLoading(false); } }, []); useEffect(() => { loadAgents(); }, [loadAgents]); const create = useCallback(async (input) => { setSaving(true); try { const payload = await createAgent(input); setAgents((previous) => [...previous, payload.agent]); return payload.agent; } finally { setSaving(false); } }, []); const update = useCallback(async (agentId, patch) => { setSaving(true); try { const payload = await updateAgent(agentId, patch); setAgents((previous) => previous.map((entry) => (entry.id === agentId ? payload.agent : entry)), ); return payload.agent; } finally { setSaving(false); } }, []); const setDefault = useCallback(async (agentId) => { setSaving(true); try { await setDefaultAgent(agentId); setAgents((previous) => previous.map((entry) => ({ ...entry, default: entry.id === agentId })), ); } finally { setSaving(false); } }, []); const remove = useCallback(async (agentId, options = {}) => { setSaving(true); try { await deleteAgent(agentId, options); setAgents((previous) => previous.filter((entry) => entry.id !== agentId)); } finally { setSaving(false); } }, []); return { state: { agents, loading, saving, }, actions: { create, loadAgents, remove, setDefault, update, }, }; }; ================================================ FILE: lib/public/js/components/badge.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); const kToneClasses = { success: "bg-green-500/10 text-status-success-muted", warning: "bg-yellow-500/10 text-status-warning-muted", danger: "bg-red-500/10 text-status-error-muted", neutral: "bg-gray-500/10 text-fg-muted", info: "bg-blue-500/10 text-blue-400", accent: "bg-purple-500/10 text-purple-400", cyan: "bg-cyan-500/10 text-cyan-400", secondary: "bg-indigo-500/10 text-indigo-300", }; export const Badge = ({ tone = "neutral", children }) => html` ${children} `; ================================================ FILE: lib/public/js/components/channel-account-status-badge.js ================================================ import { h } from "preact"; import htm from "htm"; import { Badge } from "./badge.js"; const html = htm.bind(h); export const ChannelAccountStatusBadge = ({ status = "configured", ownerAgentName = "", showAgentBadge = false, channelId = "", pairedCount = 0, }) => { const normalizedStatus = String(status || "").trim(); if (normalizedStatus !== "paired") { return html`<${Badge} tone="warning">Awaiting pairing`; } if (showAgentBadge && ownerAgentName) { return html` <${Badge} tone="neutral"> ${ownerAgentName} `; } return html` <${Badge} tone="success"> ${channelId === "telegram" || Number(pairedCount) <= 1 ? "Paired" : `Paired (${Number(pairedCount)})`} `; }; ================================================ FILE: lib/public/js/components/channel-login-modal.js ================================================ import { h } from "https://esm.sh/preact"; import htm from "https://esm.sh/htm"; import { ActionButton } from "./action-button.js"; import { CloseIcon } from "./icons.js"; import { ModalShell } from "./modal-shell.js"; import { PageHeader } from "./page-header.js"; const html = htm.bind(h); export const ChannelLoginModal = ({ visible = false, loading = false, title = "Link Channel", output = "", error = "", runDisabled = false, runLabel = "Generate QR", runLoadingLabel = "Running...", closeLabel = "Close", onRun = async () => {}, onClose = () => {}, }) => { if (!visible) return null; const hasOutput = !!String(output || "").trim(); const hasError = !!String(error || "").trim(); const displayOutput = hasOutput ? String(output) : hasError ? String(error) : "No output yet. Generate QR to start login."; return html` <${ModalShell} visible=${visible} onClose=${onClose} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4" > <${PageHeader} title=${title} actions=${html` `} />

Click "Generate QR" to run channel login and capture terminal output.

`; export const EditorSurface = ({ editorShellClassName = "file-viewer-editor-shell", editorShellAriaHidden, editorLineNumbers, editorLineNumbersRef, editorLineNumberRowRefs, shouldUseHighlightedEditor, highlightedEditorLines, editorHighlightRef, editorHighlightLineRefs, editorTextareaRef, renderContent, handleContentInput, handleEditorKeyDown, handleEditorScroll, handleEditorSelectionChange, isEditBlocked, isPreviewOnly, textareaWrap = "soft", }) => html`
${editorLineNumbers.map( (lineNumber) => html`
{ editorLineNumberRowRefs.current[lineNumber - 1] = element; }} > ${lineNumber}
`, )}
${shouldUseHighlightedEditor ? html`
${highlightedEditorLines.map( (line) => html`
{ editorHighlightLineRefs.current[line.lineNumber - 1] = element; }} >
`, )}
<${EditorTextarea} overlay=${true} editorTextareaRef=${editorTextareaRef} renderContent=${renderContent} handleContentInput=${handleContentInput} handleEditorKeyDown=${handleEditorKeyDown} handleEditorScroll=${handleEditorScroll} handleEditorSelectionChange=${handleEditorSelectionChange} isEditBlocked=${isEditBlocked} isPreviewOnly=${isPreviewOnly} textareaWrap=${textareaWrap} />
` : html` <${EditorTextarea} overlay=${false} editorTextareaRef=${editorTextareaRef} renderContent=${renderContent} handleContentInput=${handleContentInput} handleEditorKeyDown=${handleEditorKeyDown} handleEditorScroll=${handleEditorScroll} handleEditorSelectionChange=${handleEditorSelectionChange} isEditBlocked=${isEditBlocked} isPreviewOnly=${isPreviewOnly} textareaWrap=${textareaWrap} /> `}
`; ================================================ FILE: lib/public/js/components/file-viewer/frontmatter-panel.js ================================================ import { h } from "preact"; import htm from "htm"; import { formatFrontmatterValue } from "../../lib/syntax-highlighters/index.js"; const html = htm.bind(h); export const FrontmatterPanel = ({ isMarkdownFile, parsedFrontmatter, frontmatterCollapsed, setFrontmatterCollapsed, }) => { if (!isMarkdownFile || parsedFrontmatter.entries.length <= 0) return null; return html`
${!frontmatterCollapsed ? html`
${parsedFrontmatter.entries.map((entry) => { const formattedValue = formatFrontmatterValue(entry.rawValue); const isMultilineValue = formattedValue.includes("\n"); return html`
${entry.key}
${isMultilineValue ? html`
${formattedValue}
` : html`
${formattedValue}
`}
`; })}
` : null}
`; }; ================================================ FILE: lib/public/js/components/file-viewer/index.js ================================================ import { h } from "preact"; import { useState } from "preact/hooks"; import htm from "htm"; import { LoadingSpinner } from "../loading-spinner.js"; import { ConfirmDialog } from "../confirm-dialog.js"; import { SqliteViewer } from "./sqlite-viewer.js"; import { FileViewerToolbar } from "./toolbar.js"; import { FileViewerStatusBanners } from "./status-banners.js"; import { FrontmatterPanel } from "./frontmatter-panel.js"; import { DiffViewer } from "./diff-viewer.js"; import { MediaPreview } from "./media-preview.js"; import { EditorSurface } from "./editor-surface.js"; import { MarkdownSplitView } from "./markdown-split-view.js"; import { kSqlitePageSize } from "./constants.js"; import { useFileViewer } from "./use-file-viewer.js"; const html = htm.bind(h); export const FileViewer = ({ filePath = "", isPreviewOnly = false, browseView = "edit", lineTarget = 0, lineEndTarget = 0, onRequestEdit = () => {}, onRequestClearSelection = () => {}, }) => { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const { state, derived, refs, actions, context } = useFileViewer({ filePath, isPreviewOnly, browseView, lineTarget, lineEndTarget, onRequestClearSelection, onRequestEdit, }); if (!state.hasSelectedPath || state.isFolderPath) { return html`
[ ]
Browse and edit files
Syncs to git
`; } return html`
<${FileViewerToolbar} pathSegments=${derived.pathSegments} isDirty=${derived.isDirty} isPreviewOnly=${state.isPreviewOnly} isDiffView=${state.isDiffView} isMarkdownFile=${state.isMarkdownFile} viewMode=${state.viewMode} handleChangeViewMode=${actions.handleChangeViewMode} handleSave=${actions.handleSave} handleDiscard=${actions.handleDiscard} loading=${state.loading} canEditFile=${derived.canEditFile} isEditBlocked=${derived.isEditBlocked} isImageFile=${state.isImageFile} isAudioFile=${state.isAudioFile} isSqliteFile=${state.isSqliteFile} saving=${state.saving} deleting=${state.deleting} restoring=${state.restoring} canDeleteFile=${derived.canDeleteFile} isDeleteBlocked=${derived.isDeleteBlocked} isProtectedFile=${derived.isProtectedFile} canRestoreDeletedDiff=${state.isDiffView && !!state.diffStatus?.isDeleted} onRequestDelete=${() => setDeleteConfirmOpen(true)} onRequestRestore=${actions.handleRestore} /> <${FileViewerStatusBanners} isDiffView=${state.isDiffView} onRequestEdit=${onRequestEdit} normalizedPath=${context.normalizedPath} isDeletedDiff=${!!state.diffStatus?.isDeleted} isLockedFile=${derived.isLockedFile} isProtectedFile=${derived.isProtectedFile} isProtectedLocked=${derived.isProtectedLocked} handleEditProtectedFile=${actions.handleEditProtectedFile} /> ${!state.isDiffView ? html` <${FrontmatterPanel} isMarkdownFile=${state.isMarkdownFile} parsedFrontmatter=${derived.parsedFrontmatter} frontmatterCollapsed=${state.frontmatterCollapsed} setFrontmatterCollapsed=${actions.setFrontmatterCollapsed} /> ` : null} ${state.loading ? html`
${state.showDelayedLoadingSpinner ? html`<${LoadingSpinner} className="h-4 w-4" />` : null}
` : state.error ? html`
${state.error}
` : state.isImageFile || state.isAudioFile ? html` <${MediaPreview} isImageFile=${state.isImageFile} imageDataUrl=${state.imageDataUrl} pathSegments=${derived.pathSegments} isAudioFile=${state.isAudioFile} audioDataUrl=${state.audioDataUrl} /> ` : state.isSqliteFile ? html` <${SqliteViewer} sqliteSummary=${state.sqliteSummary} sqliteSelectedTable=${state.sqliteSelectedTable} setSqliteSelectedTable=${actions.setSqliteSelectedTable} sqliteTableOffset=${state.sqliteTableOffset} setSqliteTableOffset=${actions.setSqliteTableOffset} sqliteTableLoading=${state.sqliteTableLoading} sqliteTableError=${state.sqliteTableError} sqliteTableData=${state.sqliteTableData} kSqlitePageSize=${kSqlitePageSize} /> ` : state.isDiffView ? html` <${DiffViewer} diffLoading=${state.diffLoading} diffError=${state.diffError} diffContent=${state.diffContent} /> ` : html` ${state.isMarkdownFile ? html` <${MarkdownSplitView} viewMode=${state.viewMode} previewRef=${refs.previewRef} handlePreviewScroll=${actions.handlePreviewScroll} previewHtml=${state.previewHtml} editorLineNumbers=${derived.editorLineNumbers} editorLineNumbersRef=${refs.editorLineNumbersRef} editorLineNumberRowRefs=${refs.editorLineNumberRowRefs} shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor} highlightedEditorLines=${derived.highlightedEditorLines} editorHighlightRef=${refs.editorHighlightRef} editorHighlightLineRefs=${refs.editorHighlightLineRefs} editorTextareaRef=${refs.editorTextareaRef} renderContent=${state.renderContent} handleContentInput=${actions.handleContentInput} handleEditorKeyDown=${actions.handleEditorKeyDown} handleEditorScroll=${actions.handleEditorScroll} handleEditorSelectionChange=${actions.handleEditorSelectionChange} isEditBlocked=${derived.isEditBlocked} isPreviewOnly=${state.isPreviewOnly} /> ` : html` <${EditorSurface} editorLineNumbers=${derived.editorLineNumbers} editorLineNumbersRef=${refs.editorLineNumbersRef} editorLineNumberRowRefs=${refs.editorLineNumberRowRefs} shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor} highlightedEditorLines=${derived.highlightedEditorLines} editorHighlightRef=${refs.editorHighlightRef} editorHighlightLineRefs=${refs.editorHighlightLineRefs} editorTextareaRef=${refs.editorTextareaRef} renderContent=${state.renderContent} handleContentInput=${actions.handleContentInput} handleEditorKeyDown=${actions.handleEditorKeyDown} handleEditorScroll=${actions.handleEditorScroll} handleEditorSelectionChange=${actions.handleEditorSelectionChange} isEditBlocked=${derived.isEditBlocked} isPreviewOnly=${state.isPreviewOnly} /> `} `} <${ConfirmDialog} visible=${deleteConfirmOpen} title="Delete file?" message=${`Delete ${context.normalizedPath || "this file"}? This can be restored from diff view before sync.`} confirmLabel="Delete" confirmLoadingLabel="Deleting..." cancelLabel="Cancel" confirmTone="warning" confirmLoading=${state.deleting} confirmDisabled=${!derived.canDeleteFile || state.deleting} onCancel=${() => { if (state.deleting) return; setDeleteConfirmOpen(false); }} onConfirm=${async () => { await actions.handleDelete(); setDeleteConfirmOpen(false); }} />
`; }; ================================================ FILE: lib/public/js/components/file-viewer/markdown-split-view.js ================================================ import { h } from "preact"; import htm from "htm"; import { EditorSurface } from "./editor-surface.js"; const html = htm.bind(h); export const MarkdownSplitView = ({ viewMode, previewRef, handlePreviewScroll, previewHtml, editorLineNumbers, editorLineNumbersRef, editorLineNumberRowRefs, shouldUseHighlightedEditor, highlightedEditorLines, editorHighlightRef, editorHighlightLineRefs, editorTextareaRef, renderContent, handleContentInput, handleEditorKeyDown, handleEditorScroll, handleEditorSelectionChange, isEditBlocked, isPreviewOnly, }) => html`
<${EditorSurface} editorShellClassName=${`file-viewer-editor-shell ${viewMode === "edit" ? "" : "file-viewer-pane-hidden"}`} editorShellAriaHidden=${viewMode === "edit" ? "false" : "true"} editorLineNumbers=${editorLineNumbers} editorLineNumbersRef=${editorLineNumbersRef} editorLineNumberRowRefs=${editorLineNumberRowRefs} shouldUseHighlightedEditor=${shouldUseHighlightedEditor} highlightedEditorLines=${highlightedEditorLines} editorHighlightRef=${editorHighlightRef} editorHighlightLineRefs=${editorHighlightLineRefs} editorTextareaRef=${editorTextareaRef} renderContent=${renderContent} handleContentInput=${handleContentInput} handleEditorKeyDown=${handleEditorKeyDown} handleEditorScroll=${handleEditorScroll} handleEditorSelectionChange=${handleEditorSelectionChange} isEditBlocked=${isEditBlocked} isPreviewOnly=${isPreviewOnly} /> `; ================================================ FILE: lib/public/js/components/file-viewer/media-preview.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const MediaPreview = ({ isImageFile, imageDataUrl, pathSegments, isAudioFile, audioDataUrl, }) => { if (isImageFile) { return html`
${imageDataUrl ? html` ${pathSegments[pathSegments.length ` : html`
Could not render image preview.
`}
`; } if (isAudioFile) { return html`
${audioDataUrl ? html` ` : html`
Could not render audio preview.
`}
`; } return null; }; ================================================ FILE: lib/public/js/components/file-viewer/scroll-sync.js ================================================ import { useRef } from "preact/hooks"; export const getScrollRatio = (element) => { if (!element) return 0; const maxScrollTop = element.scrollHeight - element.clientHeight; if (maxScrollTop <= 0) return 0; return element.scrollTop / maxScrollTop; }; export const setScrollByRatio = (element, ratio) => { if (!element) return; const maxScrollTop = element.scrollHeight - element.clientHeight; if (maxScrollTop <= 0) { element.scrollTop = 0; return; } const clampedRatio = Math.max(0, Math.min(1, ratio)); element.scrollTop = maxScrollTop * clampedRatio; }; export const useScrollSync = ({ viewMode, setViewMode, previewRef, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, }) => { const viewScrollRatioRef = useRef(0); const isSyncingScrollRef = useRef(false); const handleEditorScroll = (event) => { if (isSyncingScrollRef.current) return; const nextScrollTop = event.currentTarget.scrollTop; const nextRatio = getScrollRatio(event.currentTarget); viewScrollRatioRef.current = nextRatio; if (!editorLineNumbersRef.current) return; editorLineNumbersRef.current.scrollTop = nextScrollTop; if (editorHighlightRef.current) { editorHighlightRef.current.scrollTop = nextScrollTop; editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft; } if (previewRef.current) { isSyncingScrollRef.current = true; setScrollByRatio(previewRef.current, nextRatio); window.requestAnimationFrame(() => { isSyncingScrollRef.current = false; }); } }; const handlePreviewScroll = (event) => { if (isSyncingScrollRef.current) return; const nextRatio = getScrollRatio(event.currentTarget); viewScrollRatioRef.current = nextRatio; isSyncingScrollRef.current = true; setScrollByRatio(editorTextareaRef.current, nextRatio); setScrollByRatio(editorLineNumbersRef.current, nextRatio); setScrollByRatio(editorHighlightRef.current, nextRatio); window.requestAnimationFrame(() => { isSyncingScrollRef.current = false; }); }; const handleChangeViewMode = (nextMode) => { if (nextMode === viewMode) return; const nextRatio = viewMode === "preview" ? getScrollRatio(previewRef.current) : getScrollRatio(editorTextareaRef.current); viewScrollRatioRef.current = nextRatio; setViewMode(nextMode); window.requestAnimationFrame(() => { isSyncingScrollRef.current = true; if (nextMode === "preview") { setScrollByRatio(previewRef.current, nextRatio); } else { setScrollByRatio(editorTextareaRef.current, nextRatio); setScrollByRatio(editorLineNumbersRef.current, nextRatio); setScrollByRatio(editorHighlightRef.current, nextRatio); } window.requestAnimationFrame(() => { isSyncingScrollRef.current = false; }); }); }; return { viewScrollRatioRef, isSyncingScrollRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode, }; }; ================================================ FILE: lib/public/js/components/file-viewer/sqlite-viewer.js ================================================ import { h } from "preact"; import htm from "htm"; import { LoadingSpinner } from "../loading-spinner.js"; const html = htm.bind(h); export const SqliteViewer = ({ sqliteSummary, sqliteSelectedTable, setSqliteSelectedTable, sqliteTableOffset, setSqliteTableOffset, sqliteTableLoading, sqliteTableError, sqliteTableData, kSqlitePageSize, }) => { const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : []; const sqliteColumns = Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length ? sqliteTableData.columns : (sqliteSummary?.objects || []).find( (entry) => entry?.name === sqliteSelectedTable, )?.columns || []; const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0); const sqliteCanGoPrev = sqliteTableOffset > 0; const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows; return html`
${sqliteSummary?.objects?.length ? html`
${sqliteSummary.objects.map( (entry) => html` `, )}
${sqliteSelectedTable ? html`
${sqliteSelectedTable}
${sqliteTotalRows ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows` : "No rows"}
${sqliteTableLoading ? html`
<${LoadingSpinner} className="h-4 w-4" />
` : sqliteTableError ? html`
${sqliteTableError}
` : html`
${sqliteColumns.map( (column) => html``, )} ${sqliteRows.length ? sqliteRows.map( (row, rowIndex) => html` ${sqliteColumns.map((column) => { const cellValue = row?.[column.name]; const displayValue = cellValue === null ? "NULL" : typeof cellValue === "object" ? JSON.stringify(cellValue) : String(cellValue ?? ""); return html` `; })} `, ) : html` `}
${column.name}
${displayValue}
No rows
`} ` : html`
Select a table to view rows.
`}
` : html`
SQLite database loaded, but no tables/views were found.
`}
`; }; ================================================ FILE: lib/public/js/components/file-viewer/status-banners.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { LockLineIcon } from "../icons.js"; const html = htm.bind(h); export const FileViewerStatusBanners = ({ isDiffView, onRequestEdit, normalizedPath, isDeletedDiff = false, isLockedFile, isProtectedFile, isProtectedLocked, handleEditProtectedFile, }) => html` ${isDiffView ? html`
Viewing unsynced changes
${!isDeletedDiff ? html` <${ActionButton} onClick=${() => onRequestEdit(normalizedPath)} tone="secondary" size="sm" idleLabel="View file" /> ` : null}
` : null} ${!isDiffView && isLockedFile ? html`
<${LockLineIcon} className="file-viewer-protected-banner-icon" />
This file is managed by AlphaClaw and cannot be edited.
` : null} ${!isDiffView && isProtectedFile ? html`
Protected file. Changes may break workspace behavior.
${isProtectedLocked ? html` <${ActionButton} onClick=${handleEditProtectedFile} tone="warning" size="sm" idleLabel="Edit anyway" /> ` : null}
` : null} `; ================================================ FILE: lib/public/js/components/file-viewer/storage.js ================================================ import { kEditorSelectionStorageKey, kFileViewerModeStorageKey, } from "./constants.js"; export const readStoredFileViewerMode = () => { try { const storedMode = String( window.localStorage.getItem(kFileViewerModeStorageKey) || "", ).trim(); return storedMode === "preview" ? "preview" : "edit"; } catch { return "edit"; } }; export const readEditorSelectionStorageMap = () => { try { const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey); if (!rawStorageValue) return {}; const parsedStorageValue = JSON.parse(rawStorageValue); if (!parsedStorageValue || typeof parsedStorageValue !== "object") return {}; return parsedStorageValue; } catch { return {}; } }; export const readStoredEditorSelection = (filePath) => { const safePath = String(filePath || "").trim(); if (!safePath) return null; const storageMap = readEditorSelectionStorageMap(); const selection = storageMap[safePath]; if (!selection || typeof selection !== "object") return null; return { start: selection.start, end: selection.end, }; }; export const writeStoredEditorSelection = (filePath, selection) => { const safePath = String(filePath || "").trim(); if (!safePath || !selection || typeof selection !== "object") return; try { const nextStorageValue = readEditorSelectionStorageMap(); nextStorageValue[safePath] = { start: selection.start, end: selection.end, }; window.localStorage.setItem( kEditorSelectionStorageKey, JSON.stringify(nextStorageValue), ); } catch {} }; ================================================ FILE: lib/public/js/components/file-viewer/toolbar.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { SegmentedControl } from "../segmented-control.js"; import { DeleteBinLineIcon, RestartLineIcon, SaveFillIcon } from "../icons.js"; const html = htm.bind(h); export const FileViewerToolbar = ({ pathSegments, isDirty, isPreviewOnly, isDiffView, isMarkdownFile, viewMode, handleChangeViewMode, handleSave, handleDiscard, loading, canEditFile, isEditBlocked, isImageFile, isAudioFile, isSqliteFile, saving, deleting, restoring, canDeleteFile, isDeleteBlocked, isProtectedFile, canRestoreDeletedDiff, onRequestDelete, onRequestRestore, }) => html`
f ${pathSegments.map( (segment, index) => html` ${segment} ${index < pathSegments.length - 1 && html`>`} `, )} ${isDirty ? html`` : null}
${isPreviewOnly ? html`
Preview
` : null} ${!isDiffView && isMarkdownFile && html` <${SegmentedControl} className="mr-2.5" options=${[ { label: "edit", value: "edit" }, { label: "preview", value: "preview" }, ]} value=${viewMode} onChange=${handleChangeViewMode} /> `} ${!isDiffView ? !isImageFile && !isAudioFile && !isSqliteFile ? html` ${!isProtectedFile ? html` <${ActionButton} onClick=${onRequestDelete} disabled=${!canDeleteFile || deleting} tone="secondary" size="sm" iconOnly=${true} idleLabel="" idleIcon=${DeleteBinLineIcon} idleIconClassName="file-viewer-icon-action-icon" className="file-viewer-save-action" title=${isDeleteBlocked ? "Locked files cannot be deleted" : "Delete file"} ariaLabel="Delete file" /> ` : null} ${isDirty ? html` <${ActionButton} onClick=${handleDiscard} disabled=${loading || !canEditFile || isEditBlocked || deleting || saving} tone="secondary" size="sm" idleLabel="Discard changes" className="file-viewer-save-action" /> ` : null} <${ActionButton} onClick=${handleSave} disabled=${loading || !isDirty || !canEditFile || isEditBlocked} loading=${saving} tone=${isDirty ? "primary" : "secondary"} size="sm" idleLabel="Save" loadingLabel="Saving..." idleIcon=${SaveFillIcon} idleIconClassName="file-viewer-save-icon" className="file-viewer-save-action" /> ` : null : null} ${isDiffView && canRestoreDeletedDiff ? html` <${ActionButton} onClick=${onRequestRestore} disabled=${restoring} loading=${restoring} tone="secondary" size="sm" idleLabel="Restore" loadingLabel="Restoring..." idleIcon=${RestartLineIcon} idleIconClassName="file-viewer-save-icon" className="file-viewer-save-action" /> ` : null}
`; ================================================ FILE: lib/public/js/components/file-viewer/use-editor-line-number-sync.js ================================================ import { useCallback, useEffect } from "preact/hooks"; export const useEditorLineNumberSync = ({ enabled = false, syncKey = "", editorLineNumberRowRefs, editorHighlightLineRefs, }) => { const syncEditorLineNumberHeights = useCallback(() => { if (!enabled) return; const numberRows = editorLineNumberRowRefs?.current || []; const highlightRows = editorHighlightLineRefs?.current || []; const rowCount = Math.min(numberRows.length, highlightRows.length); for (let index = 0; index < rowCount; index += 1) { const numberRow = numberRows[index]; const highlightRow = highlightRows[index]; if (!numberRow || !highlightRow) continue; numberRow.style.height = `${highlightRow.offsetHeight}px`; } }, [editorHighlightLineRefs, editorLineNumberRowRefs, enabled]); useEffect(() => { syncEditorLineNumberHeights(); }, [syncEditorLineNumberHeights, syncKey]); useEffect(() => { if (!enabled) return () => {}; const onResize = () => syncEditorLineNumberHeights(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [enabled, syncEditorLineNumberHeights]); return { syncEditorLineNumberHeights, }; }; ================================================ FILE: lib/public/js/components/file-viewer/use-editor-selection-restore.js ================================================ import { useEffect, useRef } from "preact/hooks"; import { readStoredEditorSelection } from "./storage.js"; import { clampSelectionIndex } from "./utils.js"; import { getScrollRatio } from "./scroll-sync.js"; const getCharOffsetForLine = (text, lineNumber) => { const lines = String(text || "").split("\n"); const targetIndex = Math.max(0, Math.min(lineNumber - 1, lines.length - 1)); let offset = 0; for (let i = 0; i < targetIndex; i += 1) offset += lines[i].length + 1; return offset; }; const scrollEditorToLine = ({ lineIndex, textareaElement, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, }) => { const computedStyle = window.getComputedStyle(textareaElement); const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); const lineHeight = Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20; const nextScrollTop = Math.max( 0, lineIndex * lineHeight - textareaElement.clientHeight * 0.4, ); textareaElement.scrollTop = nextScrollTop; if (editorLineNumbersRef.current) { editorLineNumbersRef.current.scrollTop = nextScrollTop; } if (editorHighlightRef.current) { editorHighlightRef.current.scrollTop = nextScrollTop; } viewScrollRatioRef.current = getScrollRatio(textareaElement); }; const clearLineHighlights = (lineNumbersContainer) => { if (!lineNumbersContainer) return; const highlighted = lineNumbersContainer.querySelectorAll(".line-highlight-flash"); for (const row of highlighted) row.classList.remove("line-highlight-flash"); }; const highlightLineRange = (lineNumbersContainer, startIndex, endIndex) => { if (!lineNumbersContainer) return; clearLineHighlights(lineNumbersContainer); const rows = lineNumbersContainer.querySelectorAll("[data-line-row]"); const safeEnd = Math.min(endIndex, rows.length - 1); for (let i = startIndex; i <= safeEnd; i += 1) { const row = rows[i]; if (row) row.classList.add("line-highlight-flash"); } }; export const useEditorSelectionRestore = ({ canEditFile, isEditBlocked, loading, hasSelectedPath, normalizedPath, loadedFilePathRef, restoredSelectionPathRef, viewMode, content, lineTarget = 0, lineEndTarget = 0, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, }) => { const appliedLineTargetRef = useRef(""); useEffect(() => { if (lineTarget && lineTarget >= 1) return; if (!appliedLineTargetRef.current) return; clearLineHighlights(editorLineNumbersRef.current); appliedLineTargetRef.current = ""; }, [lineTarget, normalizedPath, editorLineNumbersRef]); useEffect(() => { if (isEditBlocked || !canEditFile || loading || !hasSelectedPath) return () => {}; if (loadedFilePathRef.current !== normalizedPath) return () => {}; if (viewMode !== "edit") return () => {}; if (!lineTarget || lineTarget < 1) return () => {}; const effectiveEnd = lineEndTarget && lineEndTarget >= lineTarget ? lineEndTarget : lineTarget; const lineKey = `${normalizedPath}:${lineTarget}-${effectiveEnd}`; if (appliedLineTargetRef.current === lineKey) return () => {}; let frameId = 0; let attempts = 0; const applyLineTarget = () => { const textareaElement = editorTextareaRef.current; if (!textareaElement) { attempts += 1; if (attempts < 6) frameId = window.requestAnimationFrame(applyLineTarget); return; } const safeContent = String(content || ""); const charOffset = getCharOffsetForLine(safeContent, lineTarget); textareaElement.setSelectionRange(charOffset, charOffset); const startIndex = lineTarget - 1; const endIndex = effectiveEnd - 1; window.requestAnimationFrame(() => { const nextTextareaElement = editorTextareaRef.current; if (!nextTextareaElement) return; scrollEditorToLine({ lineIndex: startIndex, textareaElement: nextTextareaElement, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, }); highlightLineRange(editorLineNumbersRef.current, startIndex, endIndex); }); appliedLineTargetRef.current = lineKey; restoredSelectionPathRef.current = normalizedPath; }; frameId = window.requestAnimationFrame(applyLineTarget); return () => { if (frameId) window.cancelAnimationFrame(frameId); }; }, [ canEditFile, isEditBlocked, loading, hasSelectedPath, normalizedPath, content, viewMode, lineTarget, lineEndTarget, loadedFilePathRef, restoredSelectionPathRef, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, ]); useEffect(() => { if (isEditBlocked) { restoredSelectionPathRef.current = ""; return () => {}; } if (!canEditFile || loading || !hasSelectedPath) return () => {}; if (loadedFilePathRef.current !== normalizedPath) return () => {}; if (restoredSelectionPathRef.current === normalizedPath) return () => {}; if (viewMode !== "edit") return () => {}; if (lineTarget && lineTarget >= 1) return () => {}; const storedSelection = readStoredEditorSelection(normalizedPath); if (!storedSelection) { restoredSelectionPathRef.current = normalizedPath; return () => {}; } let frameId = 0; let attempts = 0; const restoreSelection = () => { const textareaElement = editorTextareaRef.current; if (!textareaElement) { attempts += 1; if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); return; } const maxIndex = String(content || "").length; const start = clampSelectionIndex(storedSelection.start, maxIndex); const end = clampSelectionIndex(storedSelection.end, maxIndex); textareaElement.focus(); textareaElement.setSelectionRange(start, Math.max(start, end)); window.requestAnimationFrame(() => { const nextTextareaElement = editorTextareaRef.current; if (!nextTextareaElement) return; const safeContent = String(content || ""); const safeStart = clampSelectionIndex(start, safeContent.length); const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; scrollEditorToLine({ lineIndex, textareaElement: nextTextareaElement, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, }); }); restoredSelectionPathRef.current = normalizedPath; }; frameId = window.requestAnimationFrame(restoreSelection); return () => { if (frameId) window.cancelAnimationFrame(frameId); }; }, [ canEditFile, isEditBlocked, loading, hasSelectedPath, normalizedPath, content, viewMode, lineTarget, loadedFilePathRef, restoredSelectionPathRef, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, ]); }; ================================================ FILE: lib/public/js/components/file-viewer/use-file-diff.js ================================================ import { useEffect, useState } from "preact/hooks"; import { fetchBrowseFileDiff } from "../../lib/api.js"; export const useFileDiff = ({ hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath, }) => { const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(""); const [diffContent, setDiffContent] = useState(""); const [diffStatus, setDiffStatus] = useState({ statusKind: "", isDeleted: false, }); useEffect(() => { let active = true; if (!hasSelectedPath || !isDiffView || isPreviewOnly) { setDiffLoading(false); setDiffError(""); setDiffContent(""); setDiffStatus({ statusKind: "", isDeleted: false }); return () => { active = false; }; } const loadDiff = async () => { setDiffLoading(true); setDiffError(""); try { const data = await fetchBrowseFileDiff(normalizedPath); if (!active) return; setDiffContent(String(data?.content || "")); setDiffStatus({ statusKind: String(data?.statusKind || ""), isDeleted: !!data?.isDeleted, }); } catch (nextError) { if (!active) return; setDiffError(nextError.message || "Could not load diff"); setDiffStatus({ statusKind: "", isDeleted: false }); } finally { if (active) setDiffLoading(false); } }; loadDiff(); return () => { active = false; }; }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); return { diffLoading, diffError, diffContent, diffStatus, }; }; ================================================ FILE: lib/public/js/components/file-viewer/use-file-loader.js ================================================ import { useEffect, useRef } from "preact/hooks"; import { fetchBrowseSqliteTable, fetchFileContent } from "../../lib/api.js"; import { clearStoredFileDraft, readStoredFileDraft, updateDraftIndex, } from "../../lib/browse-draft-state.js"; import { showToast } from "../toast.js"; import { kFileRefreshIntervalMs, kSqlitePageSize } from "./constants.js"; export const useFileLoader = ({ hasSelectedPath, normalizedPath, isDiffView, isSqliteFile, sqliteSelectedTable, sqliteTableOffset, canEditFile, isFolderPath, loading, saving, initialContent, isDirty, setContent, setInitialContent, setFileKind, setImageDataUrl, setAudioDataUrl, setSqliteSummary, setSqliteSelectedTable, setSqliteTableOffset, setSqliteTableLoading, setSqliteTableError, setSqliteTableData, setError, setIsFolderPath, setExternalChangeNoticeShown, externalChangeNoticeShown, viewScrollRatioRef, setLoading, }) => { const loadedFilePathRef = useRef(""); const restoredSelectionPathRef = useRef(""); const fileRefreshInFlightRef = useRef(false); useEffect(() => { let active = true; loadedFilePathRef.current = ""; restoredSelectionPathRef.current = ""; if (!hasSelectedPath) { setContent(""); setInitialContent(""); setFileKind("text"); setImageDataUrl(""); setAudioDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); setError(""); setIsFolderPath(false); viewScrollRatioRef.current = 0; loadedFilePathRef.current = ""; return () => { active = false; }; } // Clear previous file state immediately so large content from the last // file is never rendered/parses under the next file's syntax mode. setContent(""); setInitialContent(""); setImageDataUrl(""); setAudioDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); setFileKind("text"); setError(""); setIsFolderPath(false); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; if (isDiffView) { setLoading(false); loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; return () => { active = false; }; } const loadFile = async () => { setLoading(true); setError(""); setIsFolderPath(false); try { const data = await fetchFileContent(normalizedPath); if (!active) return; const nextFileKind = data?.kind === "image" ? "image" : data?.kind === "audio" ? "audio" : data?.kind === "sqlite" ? "sqlite" : "text"; setFileKind(nextFileKind); if (nextFileKind === "image") { setImageDataUrl(String(data?.imageDataUrl || "")); setAudioDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); setContent(""); setInitialContent(""); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; return; } if (nextFileKind === "audio") { setAudioDataUrl(String(data?.audioDataUrl || "")); setImageDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); setContent(""); setInitialContent(""); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; return; } if (nextFileKind === "sqlite") { const nextSqliteSummary = data?.sqliteSummary || null; const nextObjects = nextSqliteSummary?.objects || []; const defaultTable = nextObjects.find((entry) => entry?.type === "table")?.name || nextObjects[0]?.name || ""; setSqliteSummary(nextSqliteSummary); setSqliteSelectedTable(defaultTable); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); setImageDataUrl(""); setAudioDataUrl(""); setContent(""); setInitialContent(""); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; return; } setImageDataUrl(""); setAudioDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); const nextContent = data.content || ""; const draftContent = readStoredFileDraft(normalizedPath); setContent(draftContent || nextContent); updateDraftIndex(normalizedPath, Boolean(draftContent && draftContent !== nextContent), { dispatchEvent: (event) => window.dispatchEvent(event), }); setInitialContent(nextContent); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; } catch (loadError) { if (!active) return; setFileKind("text"); setImageDataUrl(""); setAudioDataUrl(""); setSqliteSummary(null); setSqliteSelectedTable(""); setSqliteTableOffset(0); setSqliteTableLoading(false); setSqliteTableError(""); setSqliteTableData(null); const message = loadError.message || "Could not load file"; if (/path is not a file/i.test(message)) { setContent(""); setInitialContent(""); setIsFolderPath(true); setError(""); loadedFilePathRef.current = normalizedPath; restoredSelectionPathRef.current = ""; return; } setError(message); } finally { if (active) setLoading(false); } }; loadFile(); return () => { active = false; }; }, [hasSelectedPath, normalizedPath, isDiffView]); useEffect(() => { if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) { setSqliteTableData(null); setSqliteTableError(""); setSqliteTableLoading(false); return () => {}; } let active = true; const loadSqliteTable = async () => { setSqliteTableLoading(true); setSqliteTableError(""); try { const tableData = await fetchBrowseSqliteTable({ filePath: normalizedPath, table: sqliteSelectedTable, limit: kSqlitePageSize, offset: sqliteTableOffset, }); if (!active) return; setSqliteTableData(tableData); } catch (nextError) { if (!active) return; setSqliteTableError(nextError.message || "Could not load sqlite table"); } finally { if (active) setSqliteTableLoading(false); } }; loadSqliteTable(); return () => { active = false; }; }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]); useEffect(() => { if (!hasSelectedPath || isFolderPath || !canEditFile || isDiffView) return () => {}; const refreshFromDisk = async () => { if (loading || saving) return; if (fileRefreshInFlightRef.current) return; fileRefreshInFlightRef.current = true; try { const data = await fetchFileContent(normalizedPath); const diskContent = data.content || ""; if (diskContent === initialContent) { setExternalChangeNoticeShown(false); return; } // Auto-refresh only when editor has no unsaved work. if (!isDirty) { setContent(diskContent); setInitialContent(diskContent); clearStoredFileDraft(normalizedPath); updateDraftIndex(normalizedPath, false, { dispatchEvent: (event) => window.dispatchEvent(event), }); setExternalChangeNoticeShown(false); window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); return; } if (!externalChangeNoticeShown) { showToast( "This file changed on disk. Save to overwrite or reload by re-opening.", "error", ); setExternalChangeNoticeShown(true); } } catch { // Ignore transient refresh errors to avoid interrupting editing. } finally { fileRefreshInFlightRef.current = false; } }; const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs); return () => { window.clearInterval(intervalId); }; }, [ hasSelectedPath, isFolderPath, canEditFile, isDiffView, loading, saving, normalizedPath, initialContent, isDirty, externalChangeNoticeShown, ]); return { loadedFilePathRef, restoredSelectionPathRef, }; }; ================================================ FILE: lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js ================================================ import { useEffect } from "preact/hooks"; import { clearStoredFileDraft, updateDraftIndex, writeStoredFileDraft, } from "../../lib/browse-draft-state.js"; export const useFileViewerDraftSync = ({ loadedFilePathRef, normalizedPath, canEditFile, hasSelectedPath, loading, content, initialContent, }) => { useEffect(() => { if (loadedFilePathRef.current !== normalizedPath) return; if (!canEditFile || !hasSelectedPath || loading) return; if (content === initialContent) { clearStoredFileDraft(normalizedPath); updateDraftIndex(normalizedPath, false, { dispatchEvent: (event) => window.dispatchEvent(event), }); return; } writeStoredFileDraft(normalizedPath, content); updateDraftIndex(normalizedPath, true, { dispatchEvent: (event) => window.dispatchEvent(event), }); }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]); }; ================================================ FILE: lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js ================================================ import { useEffect } from "preact/hooks"; export const useFileViewerHotkeys = ({ canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave, }) => { useEffect(() => { const handleKeyDown = (event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && String(event.key || "").toLowerCase() === "s"; if (!isSaveShortcut) return; if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return; event.preventDefault(); void handleSave(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); }; ================================================ FILE: lib/public/js/components/file-viewer/use-file-viewer.js ================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { marked } from "marked"; import { deleteBrowseFile, restoreBrowseFile, saveFileContent } from "../../lib/api.js"; import { getFileSyntaxKind, highlightEditorLines, parseFrontmatter, } from "../../lib/syntax-highlighters/index.js"; import { clearStoredFileDraft, updateDraftIndex, writeStoredFileDraft, } from "../../lib/browse-draft-state.js"; import { kLockedBrowsePaths, kProtectedBrowsePaths, matchesBrowsePolicyPath, normalizeBrowsePolicyPath, } from "../../lib/browse-file-policies.js"; import { showToast } from "../toast.js"; import { kFileViewerModeStorageKey, kLargeFileSimpleEditorCharThreshold, kLargeFileSimpleEditorLineThreshold, kLoadingIndicatorDelayMs, } from "./constants.js"; import { readStoredFileViewerMode, writeStoredEditorSelection } from "./storage.js"; import { countTextLines, parsePathSegments, shouldUseSimpleEditorMode } from "./utils.js"; import { useScrollSync } from "./scroll-sync.js"; import { useFileLoader } from "./use-file-loader.js"; import { useFileDiff } from "./use-file-diff.js"; import { useFileViewerDraftSync } from "./use-file-viewer-draft-sync.js"; import { useFileViewerHotkeys } from "./use-file-viewer-hotkeys.js"; import { useEditorSelectionRestore } from "./use-editor-selection-restore.js"; import { useEditorLineNumberSync } from "./use-editor-line-number-sync.js"; export const useFileViewer = ({ filePath = "", isPreviewOnly = false, browseView = "edit", lineTarget = 0, lineEndTarget = 0, onRequestClearSelection = () => {}, onRequestEdit = () => {}, }) => { const normalizedPath = String(filePath || "").trim(); const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); const [content, setContent] = useState(""); const [initialContent, setInitialContent] = useState(""); const [fileKind, setFileKind] = useState("text"); const [imageDataUrl, setImageDataUrl] = useState(""); const [audioDataUrl, setAudioDataUrl] = useState(""); const [sqliteSummary, setSqliteSummary] = useState(null); const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); const [sqliteTableOffset, setSqliteTableOffset] = useState(0); const [sqliteTableLoading, setSqliteTableLoading] = useState(false); const [sqliteTableError, setSqliteTableError] = useState(""); const [sqliteTableData, setSqliteTableData] = useState(null); const [viewMode, setViewMode] = useState(readStoredFileViewerMode); const [loading, setLoading] = useState(false); const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [restoring, setRestoring] = useState(false); const [error, setError] = useState(""); const [isFolderPath, setIsFolderPath] = useState(false); const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false); const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set()); const editorLineNumbersRef = useRef(null); const editorHighlightRef = useRef(null); const editorTextareaRef = useRef(null); const previewRef = useRef(null); const editorLineNumberRowRefs = useRef([]); const editorHighlightLineRefs = useRef([]); const hasSelectedPath = normalizedPath.length > 0; const isImageFile = fileKind === "image"; const isAudioFile = fileKind === "audio"; const isSqliteFile = fileKind === "sqlite"; const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile; const isDiffView = String(browseView || "edit") === "diff"; const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } = useScrollSync({ viewMode, setViewMode, previewRef, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, }); const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({ hasSelectedPath, normalizedPath, isDiffView, isSqliteFile, sqliteSelectedTable, sqliteTableOffset, canEditFile, isFolderPath, loading, saving, initialContent, isDirty: canEditFile && content !== initialContent, setLoading, setContent, setInitialContent, setFileKind, setImageDataUrl, setAudioDataUrl, setSqliteSummary, setSqliteSelectedTable, setSqliteTableOffset, setSqliteTableLoading, setSqliteTableError, setSqliteTableData, setError, setIsFolderPath, setExternalChangeNoticeShown, externalChangeNoticeShown, viewScrollRatioRef, }); const { diffLoading, diffError, diffContent, diffStatus } = useFileDiff({ hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath, }); const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]); const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; const renderContent = isCurrentFileLoaded ? content : ""; const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; const isDirty = canEditFile && renderContent !== renderInitialContent; const isLockedFile = canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath); const isProtectedFile = canEditFile && !isLockedFile && matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); const isEditBlocked = isLockedFile || isProtectedLocked; const isDeleteBlocked = isLockedFile || isProtectedFile; const canDeleteFile = hasSelectedPath && !isFolderPath && !isPreviewOnly && !isDiffView && !deleting && !saving && !isDeleteBlocked; const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]); const isMarkdownFile = syntaxKind === "markdown"; const editorLineCount = useMemo(() => countTextLines(renderContent), [renderContent]); const useSimpleEditor = useMemo( () => shouldUseSimpleEditorMode({ contentLength: renderContent.length, lineCount: editorLineCount, charThreshold: kLargeFileSimpleEditorCharThreshold, lineThreshold: kLargeFileSimpleEditorLineThreshold, }), [renderContent, editorLineCount], ); const shouldUseHighlightedEditor = syntaxKind !== "plain" && !useSimpleEditor; const shouldRenderLineNumbers = !useSimpleEditor; const parsedFrontmatter = useMemo( () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }), [renderContent, isMarkdownFile], ); const highlightedEditorLines = useMemo( () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []), [renderContent, shouldUseHighlightedEditor, syntaxKind], ); const editorLineNumbers = useMemo(() => { if (!shouldRenderLineNumbers) return []; return Array.from({ length: editorLineCount }, (_, index) => index + 1); }, [editorLineCount, shouldRenderLineNumbers]); const previewHtml = useMemo( () => isMarkdownFile ? marked.parse(parsedFrontmatter.body || "", { gfm: true, breaks: true, }) : "", [parsedFrontmatter.body, isMarkdownFile], ); useEditorLineNumberSync({ enabled: shouldUseHighlightedEditor && viewMode === "edit", syncKey: `${normalizedPath}:${renderContent.length}:${highlightedEditorLines.length}`, editorLineNumberRowRefs, editorHighlightLineRefs, }); useEffect(() => { if (!isMarkdownFile && viewMode !== "edit") { setViewMode("edit"); } }, [isMarkdownFile, viewMode]); useEffect(() => { setProtectedEditBypassPaths(new Set()); }, [normalizedPath]); useEffect(() => { try { window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); } catch {} }, [viewMode]); useEffect(() => { if (!loading) { setShowDelayedLoadingSpinner(false); return () => {}; } const timer = window.setTimeout(() => { setShowDelayedLoadingSpinner(true); }, kLoadingIndicatorDelayMs); return () => window.clearTimeout(timer); }, [loading]); useFileViewerDraftSync({ loadedFilePathRef, normalizedPath, canEditFile, hasSelectedPath, loading, content, initialContent, }); useEditorSelectionRestore({ canEditFile, isEditBlocked, loading, hasSelectedPath, normalizedPath, loadedFilePathRef, restoredSelectionPathRef, viewMode, content, lineTarget, lineEndTarget, editorTextareaRef, editorLineNumbersRef, editorHighlightRef, viewScrollRatioRef, }); const handleSave = useCallback(async () => { if (!canEditFile || saving || !isDirty || isEditBlocked) return; setSaving(true); setError(""); try { await saveFileContent(normalizedPath, content); setInitialContent(content); setExternalChangeNoticeShown(false); clearStoredFileDraft(normalizedPath); updateDraftIndex(normalizedPath, false, { dispatchEvent: (event) => window.dispatchEvent(event), }); window.dispatchEvent( new CustomEvent("alphaclaw:browse-file-saved", { detail: { path: normalizedPath }, }), ); } catch (saveError) { const message = saveError.message || "Could not save file"; showToast(message, "error"); } finally { setSaving(false); } }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]); const handleDelete = useCallback(async () => { if (!canDeleteFile) return; setDeleting(true); setError(""); try { const data = await deleteBrowseFile(normalizedPath); const deletedPath = String(data?.path || normalizedPath); setExternalChangeNoticeShown(false); clearStoredFileDraft(normalizedPath); updateDraftIndex(normalizedPath, false, { dispatchEvent: (event) => window.dispatchEvent(event), }); window.dispatchEvent( new CustomEvent("alphaclaw:browse-file-saved", { detail: { path: deletedPath }, }), ); window.dispatchEvent( new CustomEvent("alphaclaw:browse-file-deleted", { detail: { path: deletedPath }, }), ); window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); showToast("File deleted", "success"); onRequestClearSelection(); } catch (deleteError) { const message = deleteError.message || "Could not delete file"; setError(message); if (/path is not a file/i.test(message)) { showToast("Only files can be deleted", "warning"); onRequestClearSelection(); } else { showToast(message, "error"); } } finally { setDeleting(false); } }, [canDeleteFile, normalizedPath, onRequestClearSelection]); const handleRestore = useCallback(async () => { if (!isDiffView || !diffStatus?.isDeleted || restoring) return; setRestoring(true); try { const data = await restoreBrowseFile(normalizedPath); const restoredPath = String(data?.path || normalizedPath); window.dispatchEvent( new CustomEvent("alphaclaw:browse-file-saved", { detail: { path: restoredPath }, }), ); window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); showToast("File restored", "success"); onRequestEdit(restoredPath); } catch (restoreError) { showToast(restoreError.message || "Could not restore file", "error"); } finally { setRestoring(false); } }, [ diffStatus?.isDeleted, isDiffView, normalizedPath, onRequestEdit, restoring, ]); useFileViewerHotkeys({ canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave, }); const handleEditProtectedFile = () => { if (!normalizedPolicyPath) return; setProtectedEditBypassPaths((previousPaths) => { const nextPaths = new Set(previousPaths); nextPaths.add(normalizedPolicyPath); return nextPaths; }); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { const textareaElement = editorTextareaRef.current; if (!textareaElement) return; if (textareaElement.disabled || textareaElement.readOnly) return; textareaElement.focus(); }); }); }; const persistDraftForContent = useCallback( (nextContent, selection = null) => { if (!hasSelectedPath || !canEditFile) return; if (selection) { writeStoredEditorSelection(normalizedPath, selection); } writeStoredFileDraft(normalizedPath, nextContent); updateDraftIndex(normalizedPath, nextContent !== initialContent, { dispatchEvent: (event) => window.dispatchEvent(event), }); }, [hasSelectedPath, canEditFile, normalizedPath, initialContent], ); const handleContentInput = (event) => { if (isEditBlocked || isPreviewOnly) return; const nextContent = event.target.value; setContent(nextContent); persistDraftForContent(nextContent, { start: event.target.selectionStart, end: event.target.selectionEnd, }); }; const handleEditorKeyDown = (event) => { if (event.key !== "Tab") return; if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return; if (isEditBlocked || isPreviewOnly || !canEditFile) return; const textareaElement = event.currentTarget; if (!textareaElement) return; event.preventDefault(); const start = Number(textareaElement.selectionStart || 0); const end = Number(textareaElement.selectionEnd || 0); textareaElement.setRangeText(" ", start, end, "end"); const nextContent = textareaElement.value; setContent(nextContent); persistDraftForContent(nextContent, { start: textareaElement.selectionStart, end: textareaElement.selectionEnd, }); }; const handleDiscard = () => { if (!canEditFile || !isDirty || saving || deleting) return; setContent(initialContent); clearStoredFileDraft(normalizedPath); updateDraftIndex(normalizedPath, false, { dispatchEvent: (event) => window.dispatchEvent(event), }); showToast("Changes discarded", "info"); }; const handleEditorSelectionChange = () => { if (!hasSelectedPath || !canEditFile || loading) return; const textareaElement = editorTextareaRef.current; if (!textareaElement) return; writeStoredEditorSelection(normalizedPath, { start: textareaElement.selectionStart, end: textareaElement.selectionEnd, }); }; return { state: { hasSelectedPath, isPreviewOnly, loading, saving, deleting, restoring, showDelayedLoadingSpinner, error, isFolderPath, isImageFile, imageDataUrl, isAudioFile, audioDataUrl, isSqliteFile, sqliteSummary, sqliteSelectedTable, sqliteTableOffset, sqliteTableLoading, sqliteTableError, sqliteTableData, isDiffView, diffLoading, diffError, diffContent, diffStatus, isMarkdownFile, frontmatterCollapsed, previewHtml, viewMode, renderContent, }, derived: { pathSegments, isDirty, canEditFile, canDeleteFile, isDeleteBlocked, isEditBlocked, isLockedFile, isProtectedFile, isProtectedLocked, shouldUseHighlightedEditor, shouldRenderLineNumbers, parsedFrontmatter, highlightedEditorLines, editorLineNumbers, }, refs: { previewRef, editorLineNumbersRef, editorLineNumberRowRefs, editorHighlightRef, editorHighlightLineRefs, editorTextareaRef, }, actions: { setFrontmatterCollapsed, setSqliteSelectedTable, setSqliteTableOffset, handleChangeViewMode, handleSave, handleDiscard, handleDelete, handleRestore, handleEditProtectedFile, handleContentInput, handleEditorKeyDown, handleEditorScroll, handlePreviewScroll, handleEditorSelectionChange, }, context: { normalizedPath, }, }; }; ================================================ FILE: lib/public/js/components/file-viewer/utils.js ================================================ export const parsePathSegments = (inputPath) => String(inputPath || "") .split("/") .map((part) => part.trim()) .filter(Boolean); export const clampSelectionIndex = (value, maxValue) => { const numericValue = Number.parseInt(String(value ?? ""), 10); if (!Number.isFinite(numericValue)) return 0; return Math.max(0, Math.min(maxValue, numericValue)); }; export const countTextLines = (content) => { const text = String(content || ""); if (!text) return 1; return text.split(/\r\n|\r|\n/).length; }; export const shouldUseSimpleEditorMode = ({ contentLength = 0, lineCount = 1, charThreshold = 250000, lineThreshold = 5000, }) => Number(contentLength) > Number(charThreshold) || Number(lineCount) > Number(lineThreshold); ================================================ FILE: lib/public/js/components/gateway.js ================================================ import { h } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import htm from "htm"; import { UpdateActionButton } from "./update-action-button.js"; const html = htm.bind(h); const formatDuration = (ms) => { const safeMs = Number(ms || 0); if (!Number.isFinite(safeMs) || safeMs <= 0) return "0s"; const totalSeconds = Math.floor(safeMs / 1000); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (days > 0) return `${days}d ${hours % 24}h ${minutes}m ${seconds}s`; if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; }; export const Gateway = ({ status, restarting = false, onRestart, watchdogStatus = null, onOpenWatchdog, onRepair, repairing = false, }) => { const [nowMs, setNowMs] = useState(() => Date.now()); const isRunning = status === "running" && !restarting; const dotClass = isRunning ? "ac-status-dot ac-status-dot--healthy" : "w-2 h-2 rounded-full bg-yellow-500 animate-pulse"; const watchdogHealth = watchdogStatus?.lifecycle === "crash_loop" ? "crash_loop" : watchdogStatus?.health; const watchdogDotClass = watchdogHealth === "healthy" ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset" : watchdogHealth === "degraded" ? "bg-yellow-500" : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop" ? "bg-red-500" : "bg-gray-500"; const watchdogLabel = watchdogHealth === "unknown" ? "initializing" : watchdogHealth || "unknown"; const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress; const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog; const showRepairButton = isRepairInProgress || (watchdogStatus?.health === "degraded" && !onOpenWatchdog) || watchdogStatus?.lifecycle === "crash_loop" || watchdogStatus?.health === "unhealthy" || watchdogStatus?.health === "crashed"; const liveUptimeMs = useMemo(() => { const startedAtMs = watchdogStatus?.uptimeStartedAt ? Date.parse(watchdogStatus.uptimeStartedAt) : null; if (Number.isFinite(startedAtMs)) { return Math.max(0, nowMs - startedAtMs); } return watchdogStatus?.uptimeMs || 0; }, [watchdogStatus?.uptimeStartedAt, watchdogStatus?.uptimeMs, nowMs]); useEffect(() => { const id = setInterval(() => { setNowMs(Date.now()); }, 1000); return () => clearInterval(id); }, []); return html`
Gateway: ${restarting ? "restarting..." : status || "checking..."}
${!restarting && isRunning ? html` Uptime: ${formatDuration(liveUptimeMs)} ` : null} <${UpdateActionButton} onClick=${onRestart} disabled=${!status} loading=${restarting} warning=${false} idleLabel="Restart" loadingLabel="On it..." />
${onOpenWatchdog ? html` ` : html`
Watchdog: ${watchdogLabel}
`} ${onRepair ? html`
${showInspectButton ? html` <${UpdateActionButton} onClick=${onOpenWatchdog} warning=${false} idleLabel="Inspect" loadingLabel="Inspect" className="w-full justify-center" /> ` : showRepairButton ? html` <${UpdateActionButton} onClick=${onRepair} loading=${isRepairInProgress} warning=${true} idleLabel="Repair" loadingLabel="Repairing..." className="w-full justify-center" /> ` : html``}
` : null}
`; }; ================================================ FILE: lib/public/js/components/general/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { Gateway } from "../gateway.js"; import { Channels } from "../channels.js"; import { ChannelOperationsPanel } from "../channel-operations-panel.js"; import { Pairings } from "../pairings.js"; import { DevicePairings } from "../device-pairings.js"; import { ActionButton } from "../action-button.js"; import { Google } from "../google/index.js"; import { Features } from "../features.js"; import { GeneralDoctorWarning } from "../doctor/general-warning.js"; import { ChevronDownIcon } from "../icons.js"; import { UpdateActionButton } from "../update-action-button.js"; import { useGeneralTab } from "./use-general-tab.js"; const html = htm.bind(h); const openWhatsAppQrModal = () => { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent("alphaclaw:open-whatsapp-qr")); }; export const GeneralTab = ({ statusData = null, watchdogData = null, doctorStatusData = null, agents = [], doctorWarningDismissedUntilMs = 0, onRefreshStatuses = () => {}, onSwitchTab = () => {}, onNavigate = () => {}, onOpenGmailWebhook = () => {}, isActive = false, restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, onRestartRequired = () => {}, onDismissDoctorWarning = () => {}, }) => { const { state, actions } = useGeneralTab({ statusData, watchdogData, doctorStatusData, onRefreshStatuses, isActive, restartSignal, }); const whatsappStatus = state.channels?.whatsapp || null; const whatsappAccounts = whatsappStatus?.accounts && typeof whatsappStatus.accounts === "object" ? whatsappStatus.accounts : {}; const hasWhatsAppAwaitingPairing = Object.keys(whatsappAccounts).length > 0 ? Object.values(whatsappAccounts).some( (account) => account && account.status !== "paired", ) : String(whatsappStatus?.status || "").trim() === "configured"; const showWhatsAppPairingCard = state.hasUnpaired && !state.pairingStatusRefreshing && Array.isArray(state.pending) && state.pending.length === 0 && hasWhatsAppAwaitingPairing; const showPairings = state.hasUnpaired || (Array.isArray(state.pending) && state.pending.length > 0) || state.pairingStatusRefreshing; return html`
<${Gateway} status=${state.gatewayStatus} restarting=${restartingGateway} onRestart=${onRestartGateway} watchdogStatus=${state.watchdogStatus} onOpenWatchdog=${() => onSwitchTab("watchdog")} onRepair=${actions.handleWatchdogRepair} repairing=${state.repairingWatchdog} /> <${GeneralDoctorWarning} doctorStatus=${state.doctorStatus} dismissedUntilMs=${doctorWarningDismissedUntilMs} onOpenDoctor=${() => onSwitchTab("doctor")} onDismiss=${onDismissDoctorWarning} /> <${ChannelOperationsPanel} channelsSection=${html` <${Channels} channels=${state.channels} agents=${agents} onNavigate=${onNavigate} onRefreshStatuses=${onRefreshStatuses} onRestartGateway=${onRestartGateway} /> `} pairingsSection=${html` ${showWhatsAppPairingCard ? html`

Pending Pairings

WhatsApp needs to be linked

Scan the QR code to finish pairing this channel.

<${ActionButton} onClick=${openWhatsAppQrModal} tone="primary" size="sm" idleLabel="Open QR Code" />
` : html` <${Pairings} pending=${state.pending} channels=${state.channels} visible=${showPairings} pollingInFlight=${state.pairingsPolling} statusRefreshing=${state.pairingStatusRefreshing} onApprove=${actions.handleApprove} onReject=${actions.handleReject} /> `} `} /> <${Features} onSwitchTab=${onSwitchTab} /> <${Google} gatewayStatus=${state.gatewayStatus} onRestartRequired=${onRestartRequired} onOpenGmailWebhook=${onOpenGmailWebhook} /> ${state.repo && html`
Auto-sync
<${ChevronDownIcon} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted" />
`}

OpenClaw Gateway Dashboard

<${UpdateActionButton} onClick=${actions.handleOpenDashboard} loading=${state.dashboardLoading} warning=${false} idleLabel="Open" loadingLabel="Opening..." />
<${DevicePairings} pending=${state.devicePending} onApprove=${actions.handleDeviceApprove} onReject=${actions.handleDeviceReject} />
`; }; ================================================ FILE: lib/public/js/components/general/use-general-tab.js ================================================ import { useEffect, useRef, useState } from "preact/hooks"; import { approveDevice, approvePairing, fetchDashboardUrl, fetchDevicePairings, fetchPairings, rejectDevice, rejectPairing, triggerWatchdogRepair, updateSyncCron, } from "../../lib/api.js"; import { usePolling } from "../../hooks/usePolling.js"; import { showToast } from "../toast.js"; import { ALL_CHANNELS } from "../channels.js"; const kDefaultSyncCronSchedule = "0 * * * *"; export const useGeneralTab = ({ statusData = null, watchdogData = null, doctorStatusData = null, onRefreshStatuses = () => {}, isActive = false, restartSignal = 0, } = {}) => { const [dashboardLoading, setDashboardLoading] = useState(false); const [repairingWatchdog, setRepairingWatchdog] = useState(false); const [syncCronEnabled, setSyncCronEnabled] = useState(true); const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule); const [savingSyncCron, setSavingSyncCron] = useState(false); const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule); const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false); const [devicePollingEnabled, setDevicePollingEnabled] = useState(false); const [cliAutoApproveComplete, setCliAutoApproveComplete] = useState(false); const pairingRefreshTimerRef = useRef(null); const status = statusData; const watchdogStatus = watchdogData; const doctorStatus = doctorStatusData; const gatewayStatus = status?.gateway ?? null; const channels = status?.channels ?? null; const repo = status?.repo || null; const syncCron = status?.syncCron || null; const openclawVersion = status?.openclawVersion || null; const hasUnpaired = ALL_CHANNELS.some((channel) => { const info = channels?.[channel]; if (!info) return false; const accounts = info.accounts && typeof info.accounts === "object" ? info.accounts : {}; if (Object.keys(accounts).length > 0) { return Object.values(accounts).some( (acc) => acc && acc.status !== "paired", ); } return info.status !== "paired"; }); const hasConfiguredPairingChannel = ALL_CHANNELS.some((channel) => Boolean(channels?.[channel]), ); const pairingsPoll = usePolling( async () => { const data = await fetchPairings(); return data.pending || []; }, 3000, { enabled: hasConfiguredPairingChannel && gatewayStatus === "running", cacheKey: "/api/pairings", dedupeInFlight: true, }, ); const pending = pairingsPoll.data || []; const shouldPollDevices = gatewayStatus === "running" && (devicePollingEnabled || !cliAutoApproveComplete); const devicePoll = usePolling( async () => { const data = await fetchDevicePairings(); setCliAutoApproveComplete(data?.cliAutoApproveComplete === true); return data.pending || []; }, 5000, { enabled: shouldPollDevices, cacheKey: "/api/devices", }, ); const devicePending = devicePoll.data || []; useEffect(() => { if (!restartSignal || !isActive) return; onRefreshStatuses(); pairingsPoll.refresh({ force: true }); if (shouldPollDevices) { devicePoll.refresh(); } const t1 = setTimeout(() => { onRefreshStatuses(); pairingsPoll.refresh(); if (shouldPollDevices) { devicePoll.refresh(); } }, 1200); const t2 = setTimeout(() => { onRefreshStatuses(); pairingsPoll.refresh(); if (shouldPollDevices) { devicePoll.refresh(); } }, 3500); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [ devicePoll.refresh, isActive, onRefreshStatuses, pairingsPoll.refresh, restartSignal, devicePollingEnabled, shouldPollDevices, ]); useEffect(() => { if (!syncCron) return; setSyncCronEnabled(syncCron.enabled !== false); setSyncCronSchedule(syncCron.schedule || kDefaultSyncCronSchedule); setSyncCronChoice( syncCron.enabled === false ? "disabled" : syncCron.schedule || kDefaultSyncCronSchedule, ); }, [syncCron?.enabled, syncCron?.schedule]); useEffect( () => () => { if (pairingRefreshTimerRef.current) { clearTimeout(pairingRefreshTimerRef.current); } }, [], ); const refreshAfterPairingAction = () => { setPairingStatusRefreshing(true); if (pairingRefreshTimerRef.current) { clearTimeout(pairingRefreshTimerRef.current); } pairingRefreshTimerRef.current = setTimeout(() => { setPairingStatusRefreshing(false); pairingRefreshTimerRef.current = null; }, 2800); onRefreshStatuses(); pairingsPoll.refresh({ force: true }); setTimeout(() => { onRefreshStatuses(); pairingsPoll.refresh(); }, 700); setTimeout(() => { onRefreshStatuses(); pairingsPoll.refresh(); }, 1800); }; const saveSyncCronSettings = async ({ enabled = syncCronEnabled, schedule = syncCronSchedule, } = {}) => { if (savingSyncCron) return; setSavingSyncCron(true); try { const data = await updateSyncCron({ enabled, schedule }); if (!data.ok) { throw new Error(data.error || "Could not save sync settings"); } showToast("Sync schedule updated", "success"); onRefreshStatuses(); } catch (err) { showToast(err.message || "Could not save sync settings", "error"); } finally { setSavingSyncCron(false); } }; const handleSyncCronChoiceChange = async (nextChoice) => { setSyncCronChoice(nextChoice); const nextEnabled = nextChoice !== "disabled"; const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule; setSyncCronEnabled(nextEnabled); setSyncCronSchedule(nextSchedule); await saveSyncCronSettings({ enabled: nextEnabled, schedule: nextSchedule, }); }; const handleApprove = async (id, channel, accountId = "") => { try { const result = await approvePairing(id, channel, accountId); if (!result.ok) throw new Error(result.error || "Could not approve pairing"); refreshAfterPairingAction(); } catch (err) { showToast(err.message || "Could not approve pairing", "error"); throw err; } }; const handleReject = async (id, channel, accountId = "") => { try { await rejectPairing(id, channel, accountId); refreshAfterPairingAction(); } catch (err) { showToast(err.message || "Could not reject pairing", "error"); throw err; } }; const handleDeviceApprove = async (id) => { try { await approveDevice(id); showToast("Device pairing approved", "success"); setTimeout(devicePoll.refresh, 500); setTimeout(devicePoll.refresh, 2000); } catch (err) { showToast(err.message || "Could not approve device pairing", "error"); throw err; } }; const handleDeviceReject = async (id) => { try { await rejectDevice(id); showToast("Device pairing rejected", "info"); setTimeout(devicePoll.refresh, 500); setTimeout(devicePoll.refresh, 2000); } catch (err) { showToast(err.message || "Could not reject device pairing", "error"); throw err; } }; const handleWatchdogRepair = async () => { if (repairingWatchdog) return; setRepairingWatchdog(true); try { const data = await triggerWatchdogRepair(); if (!data.ok) throw new Error(data.error || "Repair failed"); showToast("Repair triggered", "success"); setTimeout(() => { onRefreshStatuses(); }, 800); } catch (err) { showToast(err.message || "Could not run repair", "error"); } finally { setRepairingWatchdog(false); } }; const handleOpenDashboard = async () => { if (dashboardLoading) return; setDevicePollingEnabled(true); setDashboardLoading(true); try { const data = await fetchDashboardUrl(); if (data.needsAuth) { showToast( "OpenClaw dashboard token is missing from the AlphaClaw server environment", "warning", ); } window.open(data.url || "/openclaw", "_blank"); } catch (err) { showToast(err.message || "Could not open OpenClaw dashboard", "error"); window.open("/openclaw", "_blank"); } finally { setDashboardLoading(false); } }; return { state: { channels, dashboardLoading, devicePending, doctorStatus, gatewayStatus, hasUnpaired, openclawVersion, pending, pairingsPolling: pairingsPoll.isPolling, pairingStatusRefreshing, repairingWatchdog, repo, savingSyncCron, syncCron, syncCronChoice, syncCronEnabled, syncCronSchedule, syncCronStatusText: syncCronEnabled ? "Enabled" : "Disabled", watchdogStatus, }, actions: { handleApprove, handleDeviceApprove, handleDeviceReject, handleOpenDashboard, handleReject, handleSyncCronChoiceChange, handleWatchdogRepair, }, }; }; ================================================ FILE: lib/public/js/components/global-restart-banner.js ================================================ import { h } from "preact"; import htm from "htm"; import { UpdateActionButton } from "./update-action-button.js"; import { CloseIcon } from "./icons.js"; const html = htm.bind(h); export const GlobalRestartBanner = ({ visible = false, restarting = false, onRestart, onDismiss = () => {}, }) => { if (!visible) return null; return html`

Gateway restart required to apply pending configuration changes.

<${UpdateActionButton} onClick=${onRestart} disabled=${restarting} loading=${restarting} warning=${true} idleLabel="Restart Gateway" loadingLabel="Restarting..." className="global-restart-banner__button" />
`; }; ================================================ FILE: lib/public/js/components/google/account-row.js ================================================ import { h } from "preact"; import htm from "htm"; import { Badge } from "../badge.js"; import { ScopePicker } from "../scope-picker.js"; import { GmailWatchToggle } from "./gmail-watch-toggle.js"; const html = htm.bind(h); const scopeListsEqual = (a = [], b = []) => a.length === b.length && a.every((scope) => b.includes(scope)); export const GoogleAccountRow = ({ account, personal = false, expanded, onToggleExpanded, scopes = [], savedScopes = [], apiStatus = {}, checkingApis = false, onToggleScope, onCheckApis, onUpdatePermissions, onEditCredentials, onDisconnect, gmailWatchStatus = null, gmailWatchBusy = false, onEnableGmailWatch, onDisableGmailWatch, onOpenGmailSetup, onOpenGmailWebhook, }) => { const scopesChanged = !scopeListsEqual(scopes, savedScopes); return html`
${expanded ? html`
Select permissions ${account.authenticated ? html`` : null}
<${ScopePicker} scopes=${scopes} onToggle=${(scope) => onToggleScope?.(account.id, scope)} apiStatus=${account.authenticated ? apiStatus : {}} loading=${account.authenticated && checkingApis} /> ${account.authenticated ? html`
Incoming events
<${GmailWatchToggle} account=${account} watchStatus=${gmailWatchStatus} busy=${gmailWatchBusy} onEnable=${() => onEnableGmailWatch?.(account.id)} onDisable=${() => onDisableGmailWatch?.(account.id)} onOpenWebhook=${() => onOpenGmailWebhook?.()} />
` : null}
` : null}
`; }; ================================================ FILE: lib/public/js/components/google/add-account-modal.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; import { CloseIcon } from "../icons.js"; const html = htm.bind(h); export const AddGoogleAccountModal = ({ visible, onClose, onSubmit, loading = false, defaultEmail = "", title = "Add Company Account", }) => { const [email, setEmail] = useState(""); const [error, setError] = useState(""); useEffect(() => { if (!visible) return; setEmail(String(defaultEmail || "")); setError(""); }, [visible, defaultEmail]); if (!visible) return null; const submit = async () => { setError(""); const nextEmail = String(email || "").trim(); if (!nextEmail) { setError("Email is required"); return; } await onSubmit?.({ email: nextEmail, setError, }); }; return html`<${ModalShell} visible=${visible} onClose=${onClose} closeOnOverlayClick=${false} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-md w-full space-y-4" > <${PageHeader} title=${title} actions=${html` `} />

This adds another account to the same company workspace. Only one company workspace is supported.

setEmail(e.target.value)} placeholder="you@company.com" class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
${error ? html`
${error}
` : null}
<${ActionButton} onClick=${submit} disabled=${loading} loading=${loading} tone="primary" size="lg" idleLabel="Add Account" loadingLabel="Saving..." className="w-full px-4 py-2 rounded-lg text-sm" />
`; }; ================================================ FILE: lib/public/js/components/google/gmail-setup-wizard.js ================================================ import { h } from "preact"; import { useCallback, useEffect, useMemo, useState, } from "preact/hooks"; import htm from "htm"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; import { CloseIcon } from "../icons.js"; import { ActionButton } from "../action-button.js"; import { SessionSelectField } from "../session-select-field.js"; import { sendAgentMessage } from "../../lib/api.js"; import { showToast } from "../toast.js"; import { useAgentSessions } from "../../hooks/useAgentSessions.js"; import { kNoDestinationSessionValue, useDestinationSessionSelection, } from "../../hooks/use-destination-session-selection.js"; import { getSessionDisplayLabel, kDestinationSessionFilter, } from "../../lib/session-keys.js"; const html = htm.bind(h); const copyText = async (value) => { const text = String(value || ""); if (!text) return false; try { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {} try { const element = document.createElement("textarea"); element.value = text; element.setAttribute("readonly", ""); element.style.position = "fixed"; element.style.opacity = "0"; document.body.appendChild(element); element.select(); document.execCommand("copy"); document.body.removeChild(element); return true; } catch { return false; } }; const kSetupStepTitles = [ "Install + Authenticate gcloud", "Enable APIs", "Create Topic + IAM", "Create Push Subscription", "Build with your Agent", ]; const kTutorialStepTitles = kSetupStepTitles.slice(0, 3); const kNoSessionSelectedValue = kNoDestinationSessionValue; const renderCommandBlock = (command = "", onCopy = () => {}) => html`
${command}
`; export const GmailSetupWizard = ({ visible = false, account = null, clientConfig = null, saving = false, onClose = () => {}, onSaveSetup = async () => {}, onFinish = async () => {}, }) => { const [step, setStep] = useState(0); const [projectIdInput, setProjectIdInput] = useState(""); const [editingProjectId, setEditingProjectId] = useState(false); const [localError, setLocalError] = useState(""); const [projectIdResolved, setProjectIdResolved] = useState(false); const [watchEnabled, setWatchEnabled] = useState(false); const [sendingToAgent, setSendingToAgent] = useState(false); const [agentMessageSent, setAgentMessageSent] = useState(false); const [existingWebhookAtOpen, setExistingWebhookAtOpen] = useState(false); const { selectedSessionKey, setSelectedSessionKey, loading: loadingAgentSessions, error: agentSessionsError, } = useAgentSessions({ enabled: visible, filter: kDestinationSessionFilter, }); const { sessions: selectableAgentSessions, destinationSessionKey, setDestinationSessionKey, selectedDestination, } = useDestinationSessionSelection({ enabled: visible, resetKey: String(account?.id || ""), }); useEffect(() => { if (!visible) return; setStep(0); setLocalError(""); setProjectIdInput(""); setEditingProjectId(false); setProjectIdResolved(false); setWatchEnabled(false); setSendingToAgent(false); setAgentMessageSent(false); setExistingWebhookAtOpen(Boolean(clientConfig?.webhookExists)); }, [visible, account?.id]); const commands = clientConfig?.commands || null; const hasProjectIdFromConfig = Boolean( String(clientConfig?.projectId || "").trim() || commands, ); const needsProjectId = editingProjectId || (!hasProjectIdFromConfig && !projectIdResolved); const detectedProjectId = String(projectIdInput || "").trim() || String(clientConfig?.projectId || "").trim() || ""; const hasExistingWebhookSetup = existingWebhookAtOpen; const stepTitles = hasExistingWebhookSetup ? kTutorialStepTitles : kSetupStepTitles; const totalSteps = stepTitles.length; const client = String(account?.client || clientConfig?.client || "default").trim() || "default"; const canAdvance = useMemo(() => { if (needsProjectId) { return String(projectIdInput || "").trim().length > 0; } return true; }, [needsProjectId, projectIdInput]); const handleCopy = useCallback(async (value) => { const ok = await copyText(value); if (ok) { showToast("Copied to clipboard", "success"); return; } showToast("Could not copy text", "error"); }, []); const handleChangeProjectId = useCallback(() => { setLocalError(""); setProjectIdInput(String(clientConfig?.projectId || "").trim()); setProjectIdResolved(false); setEditingProjectId(true); }, [clientConfig?.projectId]); const handleFinish = async () => { try { setLocalError(""); await onFinish({ client, projectId: String(projectIdInput || "").trim(), destination: selectedDestination, }); setWatchEnabled(true); setStep((prev) => Math.min(prev + 1, totalSteps - 1)); } catch (err) { setLocalError(err.message || "Could not finish setup"); } }; const handleNext = async () => { if (saving) return; if (needsProjectId) { if (!canAdvance) return; setLocalError(""); try { await onSaveSetup({ client, projectId: String(projectIdInput || "").trim(), }); setEditingProjectId(false); setProjectIdResolved(true); } catch (err) { setLocalError(err.message || "Could not save project id"); return; } return; } setStep((prev) => Math.min(prev + 1, totalSteps - 1)); }; const handleSendToAgent = async () => { if (sendingToAgent || agentMessageSent) return; try { setSendingToAgent(true); const accountEmail = String(account?.email || "this account").trim() || "this account"; const message = `I just enabled Gmail watch for "${accountEmail}", set up the webhook, ` + `and created the transform file. Help me set up what I want to do ` + `with incoming email.`; await sendAgentMessage({ message, sessionKey: selectedSessionKey, }); setAgentMessageSent(true); showToast("Message sent to your agent", "success"); } catch (err) { showToast(err.message || "Could not send message to agent", "error"); } finally { setSendingToAgent(false); } }; return html` <${ModalShell} visible=${visible} onClose=${onClose} closeOnOverlayClick=${false} closeOnEscape=${false} panelClassName="relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4" >
Gmail Pub / Sub Setup
${stepTitles.map( (title, idx) => html`
`, )}
<${PageHeader} title=${`Step ${step + 1} of ${totalSteps}: ${stepTitles[step]}`} actions=${null} /> ${localError ? html`
${localError}
` : null} ${ needsProjectId ? html`
${editingProjectId ? "Change project ID" : "Project ID required"}
setProjectIdInput(event.target.value)} class="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none" placeholder="my-gcp-project" />
` : null } ${ !needsProjectId && step === 0 ? html`
Using project ${detectedProjectId}.
If gcloud is not installed on your computer, follow the official install guide:${" "} Google Cloud SDK install docs
${renderCommandBlock( `gcloud auth login\n` + `gcloud config set project ${detectedProjectId}`, () => handleCopy( `gcloud auth login\n` + `gcloud config set project ${detectedProjectId}`, ), )} ` : null } ${ !needsProjectId && step === 1 ? renderCommandBlock(commands?.enableApis || "", () => handleCopy(commands?.enableApis || ""), ) : null } ${ !needsProjectId && step === 2 ? html` ${renderCommandBlock( `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), () => handleCopy( `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), ), )} ` : null } ${ !hasExistingWebhookSetup && !needsProjectId && step === 3 ? html` ${renderCommandBlock(commands?.createSubscription || "", () => handleCopy(commands?.createSubscription || ""), )}
<${SessionSelectField} label="Deliver to" sessions=${selectableAgentSessions} selectedSessionKey=${destinationSessionKey} onChangeSessionKey=${setDestinationSessionKey} disabled=${hasExistingWebhookSetup || loadingAgentSessions || saving} loading=${loadingAgentSessions} error=${agentSessionsError} allowNone=${true} noneValue=${kNoSessionSelectedValue} noneLabel="Default" loadingLabel="Loading sessions..." helperText=${hasExistingWebhookSetup ? "This Gmail webhook has already been created. To edit delivery routing, ask your agent." : null} selectClassName="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" helperClassName="text-xs text-fg-muted" statusClassName="text-[11px] text-fg-muted" errorClassName="text-[11px] text-status-error-muted" />
` : null } ${ !hasExistingWebhookSetup && step === 4 ? html`
Continue with your agent
Tell your OpenClaw agent about what you want to build with incoming email to continue the setup.
Send this to session
<${ActionButton} onClick=${handleSendToAgent} disabled=${!selectedSessionKey || agentMessageSent} loading=${sendingToAgent} idleLabel=${agentMessageSent ? "Sent" : "Send to Agent"} loadingLabel="Sending..." tone="primary" size="sm" className="h-[34px] px-3" />
${loadingAgentSessions ? html`
Loading sessions...
` : null} ${agentSessionsError ? html`
${agentSessionsError}
` : null}
` : null }
${ step === 0 ? html`${!needsProjectId ? html`` : html`
`}` : html`<${ActionButton} onClick=${() => setStep((prev) => Math.max(prev - 1, 0))} disabled=${saving} idleLabel="Back" tone="secondary" size="md" className="w-full justify-center" />` } ${ !hasExistingWebhookSetup && step === totalSteps - 2 ? html`<${ActionButton} onClick=${handleFinish} disabled=${false} loading=${saving} idleLabel="Enable watch" loadingLabel="Enabling..." tone="primary" size="md" className="w-full justify-center" />` : step < totalSteps - 1 ? html`<${ActionButton} onClick=${handleNext} disabled=${saving || (needsProjectId && !canAdvance)} idleLabel="Next" tone="primary" size="md" className="w-full justify-center" />` : html`<${ActionButton} onClick=${onClose} disabled=${saving || sendingToAgent} idleLabel="Done" tone="secondary" size="md" className="w-full justify-center" />` }
`; }; ================================================ FILE: lib/public/js/components/google/gmail-watch-toggle.js ================================================ import { h } from "preact"; import htm from "htm"; import { Badge } from "../badge.js"; import { ToggleSwitch } from "../toggle-switch.js"; import { InfoTooltip } from "../info-tooltip.js"; const html = htm.bind(h); const resolveWatchState = ({ watchStatus, busy = false }) => { if (busy) { const label = watchStatus?.enabled ? "Stopping" : "Starting"; return { label, tone: "warning" }; } if (!watchStatus?.enabled) return { label: "Stopped", tone: "neutral" }; if (watchStatus.enabled && !watchStatus.running) return { label: "Error", tone: "danger" }; return { label: "Watching", tone: "success" }; }; export const GmailWatchToggle = ({ account, watchStatus = null, busy = false, onEnable = () => {}, onDisable = () => {}, onOpenWebhook = () => {}, }) => { const hasGmailReadScope = Array.isArray(account?.activeScopes) ? account.activeScopes.includes("gmail:read") : Array.isArray(account?.services) ? account.services.includes("gmail:read") : false; if (!hasGmailReadScope) { return html`
Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
`; } const state = resolveWatchState({ watchStatus, busy }); const enabled = Boolean(watchStatus?.enabled); return html`
onOpenWebhook?.()} onKeyDown=${(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onOpenWebhook?.(); }} >
🔔 Gmail <${InfoTooltip} text="Watches this inbox for new email events and routes them to your agent via the Gmail hook." widthClass="w-72" />
event.stopPropagation()} onKeyDown=${(event) => event.stopPropagation()} > <${Badge} tone=${state.tone}>${state.label} <${ToggleSwitch} checked=${enabled} disabled=${busy} label="" onChange=${(nextChecked) => { if (busy) return; if (nextChecked) onEnable?.(); else onDisable?.(); }} />
`; }; ================================================ FILE: lib/public/js/components/google/index.js ================================================ import { h } from "preact"; import { useCallback, useEffect, useMemo, useState, } from "preact/hooks"; import htm from "htm"; import { checkGoogleApis, disconnectGoogle, fetchGoogleCredentials, saveGoogleAccount, } from "../../lib/api.js"; import { getDefaultScopes, toggleScopeLogic } from "../scope-picker.js"; import { CredentialsModal } from "../credentials-modal.js"; import { ConfirmDialog } from "../confirm-dialog.js"; import { showToast } from "../toast.js"; import { ActionButton } from "../action-button.js"; import { OverflowMenu, OverflowMenuItem } from "../overflow-menu.js"; import { GoogleAccountRow } from "./account-row.js"; import { AddGoogleAccountModal } from "./add-account-modal.js"; import { useGoogleAccounts } from "./use-google-accounts.js"; import { useGmailWatch } from "./use-gmail-watch.js"; import { GmailSetupWizard } from "./gmail-setup-wizard.js"; const html = htm.bind(h); const hasScopesChanged = (nextScopes = [], savedScopes = []) => nextScopes.length !== savedScopes.length || nextScopes.some((scope) => !savedScopes.includes(scope)); const isPersonalAccount = (account = {}) => Boolean(account.personal); const kGoogleIconPath = "/assets/icons/google_icon.svg"; export const Google = ({ gatewayStatus, onRestartRequired = () => {}, onOpenGmailWebhook = () => {}, }) => { const { accounts, loading, hasCompanyCredentials, refreshAccounts } = useGoogleAccounts({ gatewayStatus }); const [expandedAccountId, setExpandedAccountId] = useState(""); const [scopesByAccountId, setScopesByAccountId] = useState({}); const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({}); const [apiStatusByAccountId, setApiStatusByAccountId] = useState({}); const [checkingByAccountId, setCheckingByAccountId] = useState({}); const [addMenuOpen, setAddMenuOpen] = useState(false); const [credentialsModalState, setCredentialsModalState] = useState({ visible: false, accountId: "", client: "default", personal: false, title: "Connect Google Workspace", submitLabel: "Connect Google", defaultInstrType: "workspace", initialValues: {}, }); const [addCompanyModalOpen, setAddCompanyModalOpen] = useState(false); const [savingAddCompany, setSavingAddCompany] = useState(false); const [disconnectAccountId, setDisconnectAccountId] = useState(""); const [gmailWizardState, setGmailWizardState] = useState({ visible: false, accountId: "", }); const { loading: gmailLoading, watchByAccountId, clientConfigByClient, busyByAccountId, savingClient, refresh: refreshGmailWatch, saveClientSetup, startWatchForAccount, stopWatchForAccount, } = useGmailWatch({ gatewayStatus, accounts }); const hasPersonalAccount = useMemo( () => accounts.some((account) => isPersonalAccount(account)), [accounts], ); const hasCompanyAccount = useMemo( () => accounts.some((account) => !isPersonalAccount(account)), [accounts], ); const getAccountById = useCallback( (accountId) => accounts.find((account) => account.id === accountId) || null, [accounts], ); const ensureScopesForAccount = useCallback((account) => { const nextScopes = Array.isArray(account.activeScopes) && account.activeScopes.length ? account.activeScopes : Array.isArray(account.services) && account.services.length ? account.services : getDefaultScopes(); setSavedScopesByAccountId((prev) => ({ ...prev, [account.id]: [...nextScopes], })); setScopesByAccountId((prev) => { const current = prev[account.id]; if (!current || !hasScopesChanged(current, nextScopes)) { return { ...prev, [account.id]: [...nextScopes] }; } return prev; }); }, []); useEffect(() => { if (!accounts.length) { setExpandedAccountId(""); return; } const firstAwaitingSignInId = accounts.find((account) => !account.authenticated)?.id || ""; setExpandedAccountId((previousId) => { if (previousId && accounts.some((account) => account.id === previousId)) { return previousId; } return firstAwaitingSignInId; }); accounts.forEach((account) => ensureScopesForAccount(account)); }, [accounts, ensureScopesForAccount]); const startAuth = useCallback( (accountId) => { const account = getAccountById(accountId); if (!account) return; const scopes = scopesByAccountId[accountId] || account.activeScopes || getDefaultScopes(); if (!scopes.length) { window.alert("Select at least one service"); return; } const authUrl = `/auth/google/start?accountId=${encodeURIComponent(accountId)}` + `&services=${encodeURIComponent(scopes.join(","))}&_ts=${Date.now()}`; const popup = window.open( authUrl, `google-auth-${accountId}`, "popup=yes,width=500,height=700", ); if (!popup || popup.closed) window.location.href = authUrl; }, [getAccountById, scopesByAccountId], ); const handleToggleScope = (accountId, scope) => { setScopesByAccountId((prev) => ({ ...prev, [accountId]: toggleScopeLogic(prev[accountId] || [], scope), })); }; const handleCheckApis = useCallback(async (accountId) => { setApiStatusByAccountId((prev) => { const next = { ...prev }; delete next[accountId]; return next; }); setCheckingByAccountId({ [accountId]: true }); try { const data = await checkGoogleApis(accountId); if (data.results) { setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results, })); } } finally { setCheckingByAccountId((prev) => { if (!prev[accountId]) return prev; const next = { ...prev }; delete next[accountId]; return next; }); } }, []); useEffect(() => { const handler = async (event) => { if (event.data?.google === "success") { showToast("✓ Google account connected", "success"); const accountId = String(event.data?.accountId || "").trim(); setApiStatusByAccountId({}); await refreshAccounts(); await refreshGmailWatch(); if (accountId) { await handleCheckApis(accountId); } } else if (event.data?.google === "error") { showToast( `✗ Google auth failed: ${event.data.message || "unknown"}`, "error", ); } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [handleCheckApis, refreshAccounts, refreshGmailWatch]); useEffect(() => { if (!expandedAccountId) return; const account = getAccountById(expandedAccountId); if (!account?.authenticated) return; if (checkingByAccountId[expandedAccountId]) return; if (apiStatusByAccountId[expandedAccountId]) return; handleCheckApis(expandedAccountId); }, [ accounts, apiStatusByAccountId, checkingByAccountId, expandedAccountId, getAccountById, handleCheckApis, ]); const handleDisconnect = async (accountId) => { const data = await disconnectGoogle(accountId); if (!data.ok) { showToast(`Failed to disconnect: ${data.error || "unknown"}`, "error"); return; } showToast("Google account disconnected", "success"); setApiStatusByAccountId((prev) => { const next = { ...prev }; delete next[accountId]; return next; }); await refreshAccounts(); await refreshGmailWatch(); }; const openCredentialsModal = ({ accountId = "", client = "default", personal = false, title = "Connect Google Workspace", submitLabel = "Connect Google", defaultInstrType = personal ? "personal" : "workspace", initialValues = {}, }) => { setCredentialsModalState({ visible: true, accountId, client, personal, title, submitLabel, defaultInstrType, initialValues, }); }; const closeCredentialsModal = () => { setCredentialsModalState((prev) => ({ ...prev, visible: false })); }; const handleCredentialsSaved = async (account) => { if (account?.id) { setExpandedAccountId(account.id); } await refreshAccounts(); if (account?.id) startAuth(account.id); }; const handleAddCompanyAccount = async ({ email, setError }) => { setSavingAddCompany(true); try { const data = await saveGoogleAccount({ email, client: "default", personal: false, services: getDefaultScopes(), }); if (!data.ok) { setError?.(data.error || "Could not add account"); return; } setAddCompanyModalOpen(false); if (data.accountId) { setExpandedAccountId(data.accountId); } await refreshAccounts(); if (data.accountId) startAuth(data.accountId); } finally { setSavingAddCompany(false); } }; const handleAddCompanyClick = () => { setAddMenuOpen(false); if (hasCompanyAccount && hasCompanyCredentials) { setAddCompanyModalOpen(true); return; } openCredentialsModal({ client: "default", personal: false, title: "Add Company Account", submitLabel: "Save Credentials", defaultInstrType: "workspace", }); }; const handleAddPersonalClick = () => { setAddMenuOpen(false); openCredentialsModal({ client: "personal", personal: true, title: "Add Personal Account", submitLabel: "Save Credentials", defaultInstrType: "personal", }); }; const handleEditCredentials = async (accountId) => { const account = getAccountById(accountId); if (!account) return; const personal = isPersonalAccount(account); const client = personal ? "personal" : account.client || "default"; let credentialValues = {}; try { const credentialResponse = await fetchGoogleCredentials({ accountId: account.id, client, }); if (credentialResponse?.ok) { credentialValues = { clientId: String(credentialResponse.clientId || ""), clientSecret: String(credentialResponse.clientSecret || ""), }; } } catch { showToast("Could not load saved client credentials", "warning"); } openCredentialsModal({ accountId: account.id, client, personal, title: `Edit Credentials (${account.email})`, submitLabel: "Save Credentials", defaultInstrType: personal ? "personal" : "workspace", initialValues: { email: account.email, ...credentialValues, }, }); }; const openGmailSetupWizard = (accountId) => { setGmailWizardState({ visible: true, accountId: String(accountId || ""), }); }; const closeGmailSetupWizard = () => { setGmailWizardState({ visible: false, accountId: "", }); }; const handleEnableGmailWatch = async (accountId) => { const account = getAccountById(accountId); if (!account) return; const client = String(account.client || "default").trim() || "default"; const clientConfig = clientConfigByClient.get(client); if (!clientConfig?.configured || !clientConfig?.webhookExists) { openGmailSetupWizard(accountId); return; } try { const result = await startWatchForAccount(accountId); if (result?.restartRequired) { onRestartRequired(true); } showToast("Gmail watch enabled", "success"); } catch (err) { showToast(err.message || "Could not enable Gmail watch", "error"); } }; const handleDisableGmailWatch = async (accountId) => { try { await stopWatchForAccount(accountId); showToast("Gmail watch disabled", "info"); } catch (err) { showToast(err.message || "Could not disable Gmail watch", "error"); } }; const handleFinishGmailSetupWizard = async ({ client, projectId, destination = null, }) => { const accountId = String(gmailWizardState.accountId || "").trim(); if (!accountId) return; await saveClientSetup({ client, projectId, regeneratePushToken: false, }); await startWatchForAccount(accountId, { destination }); showToast("Gmail setup complete and watch enabled", "success"); }; const renderEmptyState = () => html`
Google logo

Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet.

<${ActionButton} onClick=${handleAddCompanyClick} tone="primary" size="sm" idleLabel="Add Company Account" className="w-full font-medium" /> <${ActionButton} onClick=${handleAddPersonalClick} tone="secondary" size="sm" idleLabel="Add Personal Account" className="w-full font-medium" />
`; return html`

Google Accounts

${accounts.length ? html`
<${OverflowMenu} open=${addMenuOpen} ariaLabel="Add Google account" title="Add Google account" onClose=${() => setAddMenuOpen(false)} onToggle=${() => setAddMenuOpen((prev) => !prev)} renderTrigger=${({ onToggle, ariaLabel, title }) => html` <${ActionButton} onClick=${onToggle} tone="subtle" size="sm" idleLabel="+ Add Account" ariaLabel=${ariaLabel} title=${title} /> `} > <${OverflowMenuItem} onClick=${handleAddCompanyClick}> Company account ${!hasPersonalAccount ? html` <${OverflowMenuItem} onClick=${handleAddPersonalClick}> Personal account ` : null}
` : null}
${loading ? html`
Loading...
` : accounts.length ? html`
${accounts.map( (account) => html`<${GoogleAccountRow} key=${account.id} account=${account} personal=${isPersonalAccount(account)} expanded=${expandedAccountId === account.id} onToggleExpanded=${(accountId) => setExpandedAccountId((prev) => prev === accountId ? "" : accountId, )} scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} apiStatus=${apiStatusByAccountId[account.id] || {}} checkingApis=${expandedAccountId === account.id && Boolean(checkingByAccountId[account.id])} onToggleScope=${handleToggleScope} onCheckApis=${handleCheckApis} onUpdatePermissions=${(accountId) => startAuth(accountId)} onEditCredentials=${handleEditCredentials} onDisconnect=${(accountId) => setDisconnectAccountId(accountId)} gmailWatchStatus=${watchByAccountId.get(account.id) || null} gmailWatchBusy=${Boolean(busyByAccountId[account.id])} onEnableGmailWatch=${handleEnableGmailWatch} onDisableGmailWatch=${handleDisableGmailWatch} onOpenGmailSetup=${openGmailSetupWizard} onOpenGmailWebhook=${onOpenGmailWebhook} />`, )}
` : renderEmptyState()}
<${CredentialsModal} visible=${credentialsModalState.visible} onClose=${closeCredentialsModal} onSaved=${handleCredentialsSaved} title=${credentialsModalState.title} submitLabel=${credentialsModalState.submitLabel} defaultInstrType=${credentialsModalState.defaultInstrType} client=${credentialsModalState.client} personal=${credentialsModalState.personal} accountId=${credentialsModalState.accountId} initialValues=${credentialsModalState.initialValues} /> <${AddGoogleAccountModal} visible=${addCompanyModalOpen} onClose=${() => setAddCompanyModalOpen(false)} onSubmit=${handleAddCompanyAccount} loading=${savingAddCompany} title="Add Company Account" /> <${GmailSetupWizard} visible=${gmailWizardState.visible} account=${getAccountById(gmailWizardState.accountId)} clientConfig=${clientConfigByClient.get( String( getAccountById(gmailWizardState.accountId)?.client || "default", ).trim() || "default", ) || null} saving=${savingClient || gmailLoading} onClose=${closeGmailSetupWizard} onSaveSetup=${saveClientSetup} onFinish=${handleFinishGmailSetupWizard} /> <${ConfirmDialog} visible=${Boolean(disconnectAccountId)} title="Disconnect Google account?" message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect." confirmLabel="Disconnect" cancelLabel="Cancel" onCancel=${() => setDisconnectAccountId("")} onConfirm=${async () => { const accountId = disconnectAccountId; setDisconnectAccountId(""); await handleDisconnect(accountId); }} /> `; }; ================================================ FILE: lib/public/js/components/google/use-gmail-watch.js ================================================ import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; import { fetchGmailConfig, renewGmailWatch, saveGmailConfig, startGmailWatch, stopGmailWatch, } from "../../lib/api.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; export const useGmailWatch = ({ gatewayStatus, accounts = [] }) => { const [busyByAccountId, setBusyByAccountId] = useState({}); const [savingClient, setSavingClient] = useState(false); const accountSignature = useMemo( () => accounts .map((entry) => String(entry?.id || "").trim()) .filter(Boolean) .sort() .join("|"), [accounts], ); const { data: config, loading, refresh: refreshCachedConfig, } = useCachedFetch("/api/gmail/config", fetchGmailConfig, { enabled: gatewayStatus === "running", maxAgeMs: 30000, }); const refresh = useCallback(async () => { return refreshCachedConfig({ force: true }); }, [refreshCachedConfig]); useEffect(() => { if (gatewayStatus !== "running") return; if (!accounts.length) return; refresh().catch(() => {}); }, [accountSignature, accounts.length, gatewayStatus, refresh]); const watchByAccountId = useMemo(() => { const map = new Map(); for (const entry of config?.accounts || []) { map.set(String(entry.accountId || ""), entry); } return map; }, [config]); const clientConfigByClient = useMemo(() => { const map = new Map(); for (const clientConfig of config?.clients || []) { map.set(String(clientConfig.client || "default"), clientConfig); } return map; }, [config]); const setBusy = (accountId, busy) => { setBusyByAccountId((prev) => { const key = String(accountId || ""); if (!key) return prev; if (busy) return { ...prev, [key]: true }; if (!prev[key]) return prev; const next = { ...prev }; delete next[key]; return next; }); }; const startWatchForAccount = useCallback(async (accountId, { destination = null } = {}) => { const key = String(accountId || ""); setBusy(key, true); try { const data = await startGmailWatch(key, { destination }); await refresh(); return data; } finally { setBusy(key, false); } }, [refresh]); const stopWatchForAccount = useCallback(async (accountId) => { const key = String(accountId || ""); setBusy(key, true); try { await stopGmailWatch(key); await refresh(); } finally { setBusy(key, false); } }, [refresh]); const renewForAccount = useCallback(async (accountId = "") => { const key = String(accountId || ""); if (key) setBusy(key, true); try { await renewGmailWatch({ accountId: key, force: true }); await refresh(); } finally { if (key) setBusy(key, false); } }, [refresh]); const saveClientSetup = useCallback(async ({ client = "default", projectId = "", regeneratePushToken = false, } = {}) => { setSavingClient(true); try { const data = await saveGmailConfig({ client, projectId, regeneratePushToken, }); await refresh(); return data; } catch (err) { const message = String(err?.message || ""); if (message.toLowerCase().includes("not found")) { throw new Error( "Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded.", ); } throw err; } finally { setSavingClient(false); } }, [refresh]); return { loading, config, watchByAccountId, clientConfigByClient, busyByAccountId, savingClient, refresh, saveClientSetup, startWatchForAccount, stopWatchForAccount, renewForAccount, }; }; ================================================ FILE: lib/public/js/components/google/use-google-accounts.js ================================================ import { useCallback, useEffect, useMemo, useRef } from "preact/hooks"; import { fetchGoogleAccounts } from "../../lib/api.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; export const useGoogleAccounts = ({ gatewayStatus }) => { const hasRefreshedAfterGatewayRunningRef = useRef(false); const { data, loading, refresh } = useCachedFetch( "/api/google/accounts", fetchGoogleAccounts, { maxAgeMs: 30000 }, ); const accounts = useMemo( () => (Array.isArray(data?.accounts) ? data.accounts : []), [data?.accounts], ); const hasCompanyCredentials = Boolean(data?.hasCompanyCredentials); const hasPersonalCredentials = Boolean(data?.hasPersonalCredentials); const refreshAccounts = useCallback(async () => { return refresh({ force: true }); }, [refresh]); useEffect(() => { if (gatewayStatus !== "running") { hasRefreshedAfterGatewayRunningRef.current = false; return; } if (hasRefreshedAfterGatewayRunningRef.current) return; hasRefreshedAfterGatewayRunningRef.current = true; refreshAccounts().catch(() => {}); }, [gatewayStatus, refreshAccounts]); return { accounts, loading, hasCompanyCredentials, hasPersonalCredentials, refreshAccounts, }; }; ================================================ FILE: lib/public/js/components/icons.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const ChevronDownIcon = ({ className = "" }) => html` `; export const CloseIcon = ({ className = "" }) => html` `; export const AddLineIcon = ({ className = "" }) => html` `; export const More2FillIcon = ({ className = "" }) => html` `; export const HomeLineIcon = ({ className = "" }) => html` `; export const FolderLineIcon = ({ className = "" }) => html` `; export const RobotLineIcon = ({ className = "" }) => html` `; export const MarkdownFillIcon = ({ className = "" }) => html` `; export const File3LineIcon = ({ className = "" }) => html` `; export const JavascriptFillIcon = ({ className = "" }) => html` `; export const Image2FillIcon = ({ className = "" }) => html` `; export const ImageAiLineIcon = ({ className = "" }) => html` `; export const Brain2LineIcon = ({ className = "" }) => html` `; export const TextToSpeechLineIcon = ({ className = "" }) => html` `; export const ChatVoiceLineIcon = ({ className = "" }) => html` `; export const Chat4LineIcon = ({ className = "" }) => html` `; export const FileMusicLineIcon = ({ className = "" }) => html` `; export const TerminalFillIcon = ({ className = "" }) => html` `; export const BracesLineIcon = ({ className = "" }) => html` `; export const FileCodeLineIcon = ({ className = "" }) => html` `; export const Database2LineIcon = ({ className = "" }) => html` `; export const HashtagIcon = ({ className = "" }) => html` `; export const BarChartLineIcon = ({ className = "" }) => html` `; export const SignalTowerLineIcon = ({ className = "" }) => html` `; export const GitBranchLineIcon = ({ className = "" }) => html` `; export const GithubFillIcon = ({ className = "" }) => html` `; export const SaveFillIcon = ({ className = "" }) => html` `; export const LockLineIcon = ({ className = "" }) => html` `; export const DeleteBinLineIcon = ({ className = "" }) => html` `; export const FileAddLineIcon = ({ className = "" }) => html` `; export const FolderAddLineIcon = ({ className = "" }) => html` `; export const DownloadLineIcon = ({ className = "" }) => html` `; export const FileCopyLineIcon = ({ className = "" }) => html` `; export const RestartLineIcon = ({ className = "" }) => html` `; export const ErrorWarningLineIcon = ({ className = "" }) => html` `; export const AlarmLineIcon = ({ className = "" }) => html` `; export const HospitalLineIcon = ({ className = "" }) => html` `; export const PulseLineIcon = ({ className = "" }) => html` `; export const EyeLineIcon = ({ className = "" }) => html` `; export const SunIcon = ({ className = "" }) => html` `; export const MoonIcon = ({ className = "" }) => html` `; export const FullscreenLineIcon = ({ className = "" }) => html` `; export const ComputerLineIcon = ({ className = "" }) => html` `; ================================================ FILE: lib/public/js/components/info-tooltip.js ================================================ import { h } from "preact"; import htm from "htm"; import { Tooltip } from "./tooltip.js"; const html = htm.bind(h); export const InfoTooltip = ({ text = "", widthClass = "w-64" }) => html` <${Tooltip} text=${text} widthClass=${widthClass}> ? `; ================================================ FILE: lib/public/js/components/loading-spinner.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const LoadingSpinner = ({ className = "h-4 w-4", ariaHidden = true, style = "", }) => html` `; ================================================ FILE: lib/public/js/components/modal-shell.js ================================================ import { h } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { createPortal } from "preact/compat"; import htm from "htm"; const html = htm.bind(h); export const ModalShell = ({ visible = false, onClose = () => {}, closeOnOverlayClick = true, closeOnEscape = true, panelClassName = "bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3", children = null, }) => { const overlayPointerDownRef = useRef(false); useEffect(() => { if (!visible || !closeOnEscape) return; const handleKeydown = (event) => { if (event.key === "Escape") onClose?.(); }; window.addEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown); }, [visible, closeOnEscape, onClose]); if (!visible) return null; return createPortal( html`
{ overlayPointerDownRef.current = event.target === event.currentTarget; }} onpointerup=${(event) => { const shouldClose = closeOnOverlayClick && overlayPointerDownRef.current && event.target === event.currentTarget; overlayPointerDownRef.current = false; if (shouldClose) onClose?.(); }} onpointercancel=${() => { overlayPointerDownRef.current = false; }} >
${children}
`, document.body, ); }; ================================================ FILE: lib/public/js/components/models-tab/index.js ================================================ import { h } from "preact"; import { useMemo } from "preact/hooks"; import htm from "htm"; import { PageHeader } from "../page-header.js"; import { LoadingSpinner } from "../loading-spinner.js"; import { ActionButton } from "../action-button.js"; import { PopActions } from "../pop-actions.js"; import { PaneShell } from "../pane-shell.js"; import { Badge } from "../badge.js"; import { useModels } from "./use-models.js"; import { buildProviderHasAuth, buildSyntheticModelEntry, getModelCatalogProvider, getModelsTabAuthProvider, getProviderSortIndex, SearchableModelPicker, } from "./model-picker.js"; import { ProviderAuthCard } from "./provider-auth-card.js"; import { getFeaturedModels, kProviderOrder, } from "../../lib/model-config.js"; const html = htm.bind(h); const deriveRequiredProviders = (configuredModels) => { const providers = new Set(); for (const modelKey of Object.keys(configuredModels)) { const provider = getModelsTabAuthProvider(modelKey); if (provider) providers.add(provider); } return [...providers]; }; const kProviderDisplayOrder = [ "anthropic", "openai", "openai-codex", ...kProviderOrder.filter((provider) => !["anthropic", "openai"].includes(provider)), ]; export const Models = ({ onRestartRequired = () => {}, agentId, embedded = false }) => { const { catalog, primary, configuredModels, authProfiles, authOrder, codexStatus, loading, saving, ready, error, isDirty, addModel, removeModel, setPrimaryModel, editProfile, editAuthOrder, getProfileValue, getEffectiveOrder, cancelChanges, saveAll, refreshCodexStatus, } = useModels(agentId); const configuredKeys = useMemo( () => new Set(Object.keys(configuredModels)), [configuredModels], ); const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]); const popularPickerModels = useMemo( () => featuredModels.filter((model) => !configuredKeys.has(model.key)), [featuredModels, configuredKeys], ); const pickerModels = useMemo(() => { return [...catalog] .filter((model) => !configuredKeys.has(model.key)) .sort((a, b) => { const providerCompare = getProviderSortIndex(getModelCatalogProvider(a)) - getProviderSortIndex(getModelCatalogProvider(b)); if (providerCompare !== 0) return providerCompare; return String(a.label || a.key).localeCompare(String(b.label || b.key)); }); }, [catalog, configuredKeys]); const requiredProviders = useMemo( () => deriveRequiredProviders(configuredModels), [configuredModels], ); const sortedProviders = useMemo(() => { const ordered = []; for (const p of kProviderDisplayOrder) { if (requiredProviders.includes(p)) ordered.push(p); } for (const p of requiredProviders) { if (!ordered.includes(p)) ordered.push(p); } return ordered; }, [requiredProviders]); const providerHasAuth = useMemo( () => buildProviderHasAuth({ authProfiles, codexStatus }), [authProfiles, codexStatus], ); const configuredModelEntries = useMemo( () => Object.keys(configuredModels).map((key) => { const catalogEntry = catalog.find((m) => m.key === key) || buildSyntheticModelEntry(key); const provider = getModelsTabAuthProvider(key); const hasAuth = !!providerHasAuth[provider]; return { key, label: catalogEntry?.label || key, provider: catalogEntry?.provider || provider, isPrimary: key === primary, hasAuth, }; }), [configuredModels, catalog, primary, providerHasAuth], ); const headerActions = html` <${PopActions} visible=${isDirty}> <${ActionButton} onClick=${cancelChanges} disabled=${saving} tone="secondary" size="sm" idleLabel="Cancel" className="text-xs" /> <${ActionButton} onClick=${saveAll} disabled=${saving} loading=${saving} loadingMode="inline" tone="primary" size="sm" idleLabel="Save changes" loadingLabel="Saving…" className="text-xs" /> `; if (!ready) { const loadingBody = html`
<${LoadingSpinner} className="h-4 w-4" /> Loading model settings...
`; if (embedded) return loadingBody; return html` <${PaneShell} header=${html`<${PageHeader} title="Models" />`} > ${loadingBody} `; } const bodyContent = html`

Available Models

${configuredModelEntries.length === 0 ? html`

No models configured. Add a model below.

` : html`
${configuredModelEntries.map( (entry) => html`
${entry.label} ${entry.isPrimary ? html`<${Badge} tone="cyan">Primary` : entry.hasAuth ? html` ` : html`<${Badge} tone="warning">Needs auth`}
`, )}
`}
<${SearchableModelPicker} options=${pickerModels} popularModels=${popularPickerModels} configuredOptions=${configuredModelEntries} placeholder="Add model..." onSelect=${(modelKey) => { addModel(modelKey); if (!primary) setPrimaryModel(modelKey); }} />
${loading ? html`

Loading model catalog...

` : error ? html`

${error}

` : null}
${sortedProviders.length > 0 ? html`

Provider Authentication

${sortedProviders.map( (provider) => html` <${ProviderAuthCard} provider=${provider} authProfiles=${authProfiles} authOrder=${authOrder} codexStatus=${codexStatus} onEditProfile=${editProfile} onEditAuthOrder=${editAuthOrder} getProfileValue=${getProfileValue} getEffectiveOrder=${getEffectiveOrder} onRefreshCodex=${refreshCodexStatus} /> `, )}
` : null} `; if (embedded) { return html`
${headerActions}
${bodyContent}
`; } return html` <${PaneShell} header=${html`<${PageHeader} title="Models" actions=${headerActions} />`} > ${bodyContent} `; }; ================================================ FILE: lib/public/js/components/models-tab/model-picker.js ================================================ import { h } from "preact"; import { useState, useMemo, useRef, useEffect } from "preact/hooks"; import htm from "htm"; import { getModelProvider, getAuthProviderFromModelProvider, kProviderLabels, kProviderOrder, } from "../../lib/model-config.js"; const html = htm.bind(h); const kProviderDisplayOrder = [ "anthropic", "openai", "openai-codex", ...kProviderOrder.filter((provider) => !["anthropic", "openai"].includes(provider)), ]; export const getModelsTabAuthProvider = (modelKey) => { const provider = getModelProvider(modelKey); if (provider === "openai-codex") return "openai-codex"; return getAuthProviderFromModelProvider(provider); }; export const getModelCatalogProvider = (model) => String(model?.provider || getModelProvider(model?.key)).trim(); export const getProviderSortIndex = (provider) => { const index = kProviderDisplayOrder.indexOf(provider); return index >= 0 ? index : Number.MAX_SAFE_INTEGER; }; const formatProviderSectionLabel = (provider) => String(kProviderLabels[provider] || provider).toUpperCase(); const normalizeSearch = (value) => String(value || "").trim().toLowerCase(); const titleCaseToken = (value) => { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) return ""; return normalized.charAt(0).toUpperCase() + normalized.slice(1); }; const formatAnthropicModelLabel = (modelId) => { const match = /^claude-(opus|sonnet|haiku)-(\d+)[-.](\d+)$/i.exec( String(modelId || "").trim(), ); if (!match) return ""; return `Claude ${titleCaseToken(match[1])} ${match[2]}.${match[3]}`; }; export const buildSyntheticModelEntry = (modelKey) => { const key = String(modelKey || "").trim(); const provider = getModelProvider(key); const modelId = key.includes("/") ? key.split("/").slice(1).join("/") : key; const anthropicLabel = provider === "anthropic" ? formatAnthropicModelLabel(modelId) : ""; return { key, provider, label: anthropicLabel || key, }; }; export const getModelDisplayLabel = (model) => model?.featuredLabel || model?.label || model?.key; const buildModelSearchText = (model) => [ model?.featuredLabel || "", model?.label || "", model?.key || "", model?.provider || getModelProvider(model?.key), ] .join(" ") .toLowerCase(); export const buildProviderHasAuth = ({ authProfiles = [], codexStatus = { connected: false }, } = {}) => { const result = {}; for (const profile of authProfiles) { if (profile?.key || profile?.token || profile?.access) { result[profile.provider] = true; } } if (codexStatus?.connected) { result["openai-codex"] = true; } return result; }; export const SearchableModelPicker = ({ options = [], popularModels = [], configuredOptions = [], placeholder = "Add model...", onSelect = () => {}, disabled = false, }) => { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const rootRef = useRef(null); const normalizedQuery = normalizeSearch(query); const filteredOptions = useMemo( () => normalizedQuery ? options.filter((option) => buildModelSearchText(option).includes(normalizedQuery), ) : options, [options, normalizedQuery], ); const matchingConfiguredOptions = useMemo( () => normalizedQuery ? configuredOptions.filter((option) => buildModelSearchText(option).includes(normalizedQuery), ) : [], [configuredOptions, normalizedQuery], ); const groupedOptions = useMemo(() => { const groups = []; const showPopularGroup = !normalizedQuery; const visibleOptionKeys = new Set(filteredOptions.map((option) => option.key)); const visiblePopularModels = popularModels.filter((model) => visibleOptionKeys.has(model.key), ); if (showPopularGroup && visiblePopularModels.length > 0) { groups.push({ provider: "popular", label: "POPULAR", options: visiblePopularModels, }); } for (const option of filteredOptions) { const provider = getModelCatalogProvider(option); const label = formatProviderSectionLabel(provider); const currentGroup = groups[groups.length - 1]; if (!currentGroup || currentGroup.provider !== provider) { groups.push({ provider, label, options: [option] }); continue; } currentGroup.options.push(option); } return groups; }, [filteredOptions, popularModels, normalizedQuery]); useEffect(() => { const handleOutsidePointer = (event) => { if (!rootRef.current?.contains(event.target)) { setOpen(false); } }; document.addEventListener("mousedown", handleOutsidePointer); return () => document.removeEventListener("mousedown", handleOutsidePointer); }, []); const handleSelect = (modelKey) => { if (!modelKey || disabled) return; onSelect(modelKey); setQuery(""); setOpen(false); }; const handleKeyDown = (event) => { const firstVisibleOption = groupedOptions[0]?.options?.[0]; if (event.key === "Escape") { setOpen(false); return; } if (event.key === "Enter" && firstVisibleOption?.key) { event.preventDefault(); handleSelect(firstVisibleOption.key); } }; return html`
{ if (disabled) return; setOpen(true); }} onInput=${(event) => { setQuery(event.target.value); setOpen(true); }} onKeyDown=${handleKeyDown} class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted disabled:opacity-50 disabled:cursor-not-allowed" /> ${open && !disabled ? html`
${filteredOptions.length > 0 ? groupedOptions.map( (group, index) => html`
0 ? "border-t border-border" : "" }`} > ${group.label}
${group.options.map( (model) => html` `, )}
`, ) : matchingConfiguredOptions.length > 0 ? html`
${matchingConfiguredOptions.length === 1 ? html` Already added above: ${getModelDisplayLabel( matchingConfiguredOptions[0], )} ` : `${matchingConfiguredOptions.length} matching models are already added above.`}
` : html`
No models match that search.
`}
` : null}
`; }; ================================================ FILE: lib/public/js/components/models-tab/provider-auth-card.js ================================================ import { h } from "preact"; import { useState, useRef, useEffect } from "preact/hooks"; import htm from "htm"; import { Badge } from "../badge.js"; import { SecretInput } from "../secret-input.js"; import { ActionButton } from "../action-button.js"; import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js"; import { isCodexAuthCallbackMessage, openCodexAuthWindow, } from "../../lib/codex-oauth-window.js"; import { showToast } from "../toast.js"; import { kProviderAuthFields, kProviderLabels, } from "../../lib/model-config.js"; const html = htm.bind(h); const kProviderMeta = { anthropic: { label: "Anthropic", modes: [ { id: "api_key", label: "API Key", profileSuffix: "default", placeholder: "sk-ant-api03-...", url: "https://console.anthropic.com", field: "key", }, { id: "token", label: "Setup Token", profileSuffix: "manual", placeholder: "sk-ant-oat01-...", hint: "From claude setup-token (uses your Claude subscription)", field: "token", }, ], }, openai: { label: "OpenAI", modes: [ { id: "api_key", label: "API Key", profileSuffix: "default", placeholder: "sk-...", url: "https://platform.openai.com", field: "key", }, ], }, "openai-codex": { label: "OpenAI Codex", modes: [{ id: "oauth", label: "Codex OAuth", isCodexOauth: true }], }, google: { label: "Gemini", modes: [ { id: "api_key", label: "API Key", profileSuffix: "default", placeholder: "AI...", url: "https://aistudio.google.com", field: "key", }, ], }, }; const kDefaultMode = { id: "api_key", label: "API Key", profileSuffix: "default", placeholder: "...", field: "key", }; const buildDefaultProviderModes = (provider) => { const fields = kProviderAuthFields[provider] || []; if (fields.length === 0) return [kDefaultMode]; return fields.map((fieldDef) => ({ id: "api_key", label: fieldDef.label || "API Key", profileSuffix: "default", placeholder: fieldDef.placeholder || "...", hint: fieldDef.hint, url: fieldDef.url, field: "key", })); }; const getProviderMeta = (provider) => kProviderMeta[provider] || { label: kProviderLabels[provider] || provider, modes: buildDefaultProviderModes(provider), }; const resolveProfileId = (mode, provider) => { const p = mode.provider || provider; return `${p}:${mode.profileSuffix || "default"}`; }; const getCredentialValue = (value) => String(value?.key || value?.token || value?.access || "").trim(); const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { const [authStarted, setAuthStarted] = useState(false); const [authWaiting, setAuthWaiting] = useState(false); const [manualInput, setManualInput] = useState(""); const [exchanging, setExchanging] = useState(false); const exchangeInFlightRef = useRef(false); const popupPollRef = useRef(null); useEffect( () => () => { if (popupPollRef.current) clearInterval(popupPollRef.current); }, [], ); const submitAuthInput = async (input) => { const normalizedInput = String(input || "").trim(); if (!normalizedInput || exchangeInFlightRef.current) return; exchangeInFlightRef.current = true; setManualInput(normalizedInput); setExchanging(true); try { const result = await exchangeCodexOAuth(normalizedInput); if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); setManualInput(""); showToast("Codex connected", "success"); setAuthStarted(false); setAuthWaiting(false); await onRefreshCodex(); } catch (err) { setAuthWaiting(false); showToast(err.message || "Codex OAuth exchange failed", "error"); } finally { exchangeInFlightRef.current = false; setExchanging(false); } }; useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); setAuthStarted(false); setAuthWaiting(false); await onRefreshCodex(); } else if (isCodexAuthCallbackMessage(e.data)) { await submitAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, "error", ); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [onRefreshCodex, submitAuthInput]); const startAuth = () => { setAuthStarted(true); setAuthWaiting(true); const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setAuthWaiting(false); return; } if (popupPollRef.current) clearInterval(popupPollRef.current); popupPollRef.current = setInterval(() => { if (popup.closed) { clearInterval(popupPollRef.current); popupPollRef.current = null; setAuthWaiting(false); } }, 500); }; const completeAuth = async () => { await submitAuthInput(manualInput); }; const handleDisconnect = async () => { const result = await disconnectCodex(); if (!result.ok) { showToast(result.error || "Failed to disconnect Codex", "error"); return; } showToast("Codex disconnected", "success"); setAuthStarted(false); setAuthWaiting(false); setManualInput(""); await onRefreshCodex(); }; return html`
Codex OAuth ${codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
${authStarted ? html`

${authWaiting ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." : "Paste the redirect URL from your browser to finish connecting."}

` : codexStatus.connected ? html`
` : html` `} ${authStarted ? html`

After login, copy the full redirect URL (starts with http://localhost:1455/auth/callback) and paste it here.

setManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> <${ActionButton} onClick=${completeAuth} disabled=${!manualInput.trim() || exchanging} loading=${exchanging} tone="primary" size="sm" idleLabel="Complete Codex OAuth" loadingLabel="Completing..." className="text-xs font-medium px-3 py-1.5" /> ` : null}
`; }; export const ProviderAuthCard = ({ provider, authProfiles, authOrder, codexStatus, onEditProfile, onEditAuthOrder, getProfileValue, getEffectiveOrder, onRefreshCodex, }) => { const meta = getProviderMeta(provider); const credentialModes = meta.modes.filter((m) => !m.isCodexOauth); const hasMultipleModes = credentialModes.length > 1; const showsInlineOauthStatus = meta.modes.some((m) => m.isCodexOauth); const effectiveOrder = getEffectiveOrder(provider); const activeProfileId = effectiveOrder?.[0] || null; const savedOrder = authOrder[provider] || null; const hasUnsavedProfileChanges = credentialModes.some((mode) => { const profileId = resolveProfileId(mode, provider); const savedValue = authProfiles.find((p) => p.id === profileId) || null; const draftValue = getProfileValue(profileId); return getCredentialValue(draftValue) !== getCredentialValue(savedValue); }); const hasUnsavedOrderChanges = JSON.stringify(effectiveOrder || null) !== JSON.stringify(savedOrder); const hasUnsavedChanges = hasUnsavedProfileChanges || hasUnsavedOrderChanges; const isConnected = credentialModes.some((mode) => { const profileId = resolveProfileId(mode, provider); const val = getProfileValue(profileId); return !!(val?.key || val?.token || val?.access); }) || (provider === "openai-codex" && !!codexStatus?.connected); const handleSetActive = (mode) => { const profileId = resolveProfileId(mode, provider); const allIds = credentialModes.map((m) => resolveProfileId(m, provider)); const ordered = [profileId, ...allIds.filter((id) => id !== profileId)]; onEditAuthOrder(provider, ordered); }; return html`

${meta.label}

${showsInlineOauthStatus && credentialModes.length === 0 ? null : hasUnsavedChanges ? html`<${Badge} tone="warning">Unsaved` : isConnected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not configured`}
${credentialModes.map((mode) => { const profileId = resolveProfileId(mode, provider); const profileProvider = mode.provider || provider; const currentValue = getProfileValue(profileId); const fieldValue = currentValue?.[mode.field] || ""; const isActive = !hasMultipleModes || activeProfileId === profileId || (!activeProfileId && mode === credentialModes[0]); return html`
${hasMultipleModes && isActive ? html`<${Badge} tone="cyan">Primary` : null} ${hasMultipleModes && !isActive && fieldValue ? html`` : null} ${mode.url && !fieldValue ? html`Get` : null}
<${SecretInput} value=${fieldValue} onInput=${(e) => { const newVal = e.target.value; const cred = { type: mode.id, provider: profileProvider, [mode.field]: newVal, }; if (currentValue?.expires) cred.expires = currentValue.expires; onEditProfile(profileId, cred); const savedProfile = authProfiles.find((p) => p.id === profileId) || null; const isReverted = getCredentialValue(cred) === getCredentialValue(savedProfile); if (isReverted && hasMultipleModes) { onEditAuthOrder(provider, savedOrder); } else if (hasMultipleModes && newVal && !isActive) { handleSetActive(mode); } }} placeholder=${mode.placeholder || ""} isSecret=${true} inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" /> ${mode.hint ? html`

${mode.hint}

` : null}
`; })} ${meta.modes.some((m) => m.isCodexOauth) ? html`
<${CodexOAuthSection} codexStatus=${codexStatus} onRefreshCodex=${onRefreshCodex} />
` : null}
`; }; ================================================ FILE: lib/public/js/components/models-tab/use-models.js ================================================ import { useState, useEffect, useRef, useCallback } from "preact/hooks"; import { fetchModels, fetchModelsConfig, saveModelsConfig, fetchCodexStatus, disconnectCodex, } from "../../lib/api.js"; import { showToast } from "../toast.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; import { usePolling } from "../../hooks/usePolling.js"; import { invalidateCache } from "../../lib/api-cache.js"; import { getModelCatalogModels, isModelCatalogRefreshing, kModelCatalogCacheKey, kModelCatalogPollIntervalMs, } from "../../lib/model-catalog.js"; let kModelsTabCache = null; const getCredentialValue = (value) => String(value?.key || value?.token || value?.access || "").trim(); const kNoModelsFoundError = "No models found"; const kModelSettingsLoadError = "Failed to load model settings"; export const useModels = (agentId) => { const isScoped = !!agentId; const normalizedAgentId = String(agentId || "").trim(); const useCache = !isScoped; const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []); const [catalogStatus, setCatalogStatus] = useState( () => (useCache && kModelsTabCache?.catalogStatus) || { source: "", fetchedAt: null, stale: false, refreshing: false, }, ); const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || ""); const [configuredModels, setConfiguredModels] = useState( () => (useCache && kModelsTabCache?.configuredModels) || {}, ); const [authProfiles, setAuthProfiles] = useState( () => (useCache && kModelsTabCache?.authProfiles) || [], ); const [authOrder, setAuthOrder] = useState( () => (useCache && kModelsTabCache?.authOrder) || {}, ); const [codexStatus, setCodexStatus] = useState( () => (useCache && kModelsTabCache?.codexStatus) || { connected: false }, ); const [loading, setLoading] = useState(() => !(useCache && kModelsTabCache)); const [saving, setSaving] = useState(false); const [ready, setReady] = useState(() => !!(useCache && kModelsTabCache)); const [error, setError] = useState(""); const [profileEdits, setProfileEdits] = useState({}); const [orderEdits, setOrderEdits] = useState({}); const savedPrimaryRef = useRef(kModelsTabCache?.primary || ""); const savedConfiguredRef = useRef(kModelsTabCache?.configuredModels || {}); const updateCache = useCallback((patch) => { if (!isScoped) kModelsTabCache = { ...(kModelsTabCache || {}), ...patch }; }, [isScoped]); const modelsConfigCacheKey = normalizedAgentId ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}` : "/api/models/config"; const catalogFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, { maxAgeMs: 30000, }); const configFetchState = useCachedFetch( modelsConfigCacheKey, () => fetchModelsConfig(isScoped ? { agentId } : undefined), { maxAgeMs: 30000 }, ); const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, { maxAgeMs: 15000, }); const catalogPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, { enabled: ready && isModelCatalogRefreshing(catalogStatus), pauseWhenHidden: true, cacheKey: kModelCatalogCacheKey, }); const syncCatalogError = useCallback((catalogModels) => { setError((current) => { if (catalogModels.length > 0) { return current === kNoModelsFoundError ? "" : current; } return current || kNoModelsFoundError; }); }, []); const applyCatalogResult = useCallback( (catalogResult) => { const catalogModels = getModelCatalogModels(catalogResult); const nextCatalogStatus = { source: String(catalogResult?.source || ""), fetchedAt: Number(catalogResult?.fetchedAt || 0) || null, stale: Boolean(catalogResult?.stale), refreshing: Boolean(catalogResult?.refreshing), }; setCatalog(catalogModels); setCatalogStatus(nextCatalogStatus); updateCache({ catalog: catalogModels, catalogStatus: nextCatalogStatus, }); syncCatalogError(catalogModels); return catalogModels; }, [syncCatalogError, updateCache], ); const refresh = useCallback(async () => { if (!ready) setLoading(true); setError(""); try { const [catalogResult, configResult, codex] = await Promise.all([ catalogFetchState.refresh({ force: true }), configFetchState.refresh({ force: true }), codexFetchState.refresh({ force: true }), ]); const catalogModels = applyCatalogResult(catalogResult); const p = configResult.primary || ""; const cm = configResult.configuredModels || {}; const ap = configResult.authProfiles || []; const ao = configResult.authOrder || {}; setPrimary(p); setConfiguredModels(cm); setAuthProfiles(ap); setAuthOrder(ao); setCodexStatus(codex || { connected: false }); setProfileEdits({}); setOrderEdits({}); savedPrimaryRef.current = p; savedConfiguredRef.current = cm; updateCache({ catalog: catalogModels, primary: p, configuredModels: cm, authProfiles: ap, authOrder: ao, codexStatus: codex || { connected: false }, }); } catch (err) { setError(kModelSettingsLoadError); showToast(`${kModelSettingsLoadError}: ${err.message}`, "error"); } finally { setReady(true); setLoading(false); } }, [ applyCatalogResult, catalogFetchState, codexFetchState, configFetchState, ready, updateCache, ]); useEffect(() => { refresh(); }, [agentId]); useEffect(() => { if (!catalogPoll.data) return; applyCatalogResult(catalogPoll.data); }, [applyCatalogResult, catalogPoll.data]); const stableStringify = (obj) => JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {})); const modelConfigDirty = primary !== savedPrimaryRef.current || stableStringify(configuredModels) !== stableStringify(savedConfiguredRef.current); const authDirty = (() => { const hasProfileChanges = Object.entries(profileEdits).some( ([id, cred]) => { const existing = authProfiles.find((p) => p.id === id); return getCredentialValue(cred) !== getCredentialValue(existing); }, ); const hasOrderChanges = Object.entries(orderEdits).some( ([provider, order]) => { const existing = authOrder[provider]; return JSON.stringify(order) !== JSON.stringify(existing); }, ); return hasProfileChanges || hasOrderChanges; })(); const isDirty = modelConfigDirty || authDirty; const addModel = useCallback( (modelKey) => { if (!modelKey) return; setConfiguredModels((prev) => { const next = { ...prev, [modelKey]: {} }; updateCache({ configuredModels: next }); return next; }); }, [updateCache], ); const removeModel = useCallback( (modelKey) => { setConfiguredModels((prev) => { const next = { ...prev }; delete next[modelKey]; updateCache({ configuredModels: next }); return next; }); if (primary === modelKey) { const remaining = Object.keys(configuredModels).filter( (k) => k !== modelKey, ); const newPrimary = remaining[0] || ""; setPrimary(newPrimary); updateCache({ primary: newPrimary }); } }, [primary, configuredModels, updateCache], ); const setPrimaryModel = useCallback( (modelKey) => { setPrimary(modelKey); updateCache({ primary: modelKey }); }, [updateCache], ); const editProfile = useCallback( (profileId, credential) => { const existing = authProfiles.find((p) => p.id === profileId); if (getCredentialValue(credential) === getCredentialValue(existing)) { setProfileEdits((prev) => { const next = { ...prev }; delete next[profileId]; return next; }); return; } setProfileEdits((prev) => ({ ...prev, [profileId]: credential })); }, [authProfiles], ); const editAuthOrder = useCallback( (provider, orderedIds) => { const existing = authOrder[provider] || null; if (JSON.stringify(orderedIds) === JSON.stringify(existing)) { setOrderEdits((prev) => { const next = { ...prev }; delete next[provider]; return next; }); return; } setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds })); }, [authOrder], ); const getProfileValue = useCallback( (profileId) => { if (profileEdits[profileId] !== undefined) return profileEdits[profileId]; const existing = authProfiles.find((p) => p.id === profileId); return existing || null; }, [profileEdits, authProfiles], ); const getEffectiveOrder = useCallback( (provider) => { if (orderEdits[provider] !== undefined) return orderEdits[provider]; return authOrder[provider] || null; }, [orderEdits, authOrder], ); const cancelChanges = useCallback(() => { const savedPrimary = savedPrimaryRef.current || ""; const savedConfigured = savedConfiguredRef.current || {}; setPrimary(savedPrimary); setConfiguredModels(savedConfigured); setProfileEdits({}); setOrderEdits({}); updateCache({ primary: savedPrimary, configuredModels: savedConfigured, }); }, [updateCache]); const saveAll = useCallback(async () => { if (saving) return; setSaving(true); try { const changedProfiles = Object.entries(profileEdits) .filter(([id, cred]) => { const existing = authProfiles.find((p) => p.id === id); return getCredentialValue(cred) !== getCredentialValue(existing); }) .map(([id, cred]) => ({ id, ...cred })); const result = await saveModelsConfig({ primary, configuredModels, profiles: changedProfiles.length > 0 ? changedProfiles : undefined, authOrder: Object.keys(orderEdits).length > 0 ? orderEdits : undefined, ...(isScoped ? { agentId } : {}), }); if (!result.ok) throw new Error(result.error || "Failed to save config"); showToast("Changes saved", "success"); if (result.syncWarning) { showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning"); } invalidateCache(kModelCatalogCacheKey); await refresh(); } catch (err) { showToast(err.message || "Failed to save changes", "error"); } finally { setSaving(false); } }, [ saving, primary, configuredModels, profileEdits, orderEdits, authProfiles, isScoped, agentId, refresh, ]); const refreshCodexStatus = useCallback(async () => { try { const codex = await fetchCodexStatus(); setCodexStatus(codex || { connected: false }); updateCache({ codexStatus: codex || { connected: false } }); } catch { setCodexStatus({ connected: false }); updateCache({ codexStatus: { connected: false } }); } }, [updateCache]); return { catalog, primary, configuredModels, authProfiles, authOrder, codexStatus, loading, saving, ready, error, isDirty, refresh, addModel, removeModel, setPrimaryModel, editProfile, editAuthOrder, getProfileValue, getEffectiveOrder, cancelChanges, saveAll, refreshCodexStatus, }; }; ================================================ FILE: lib/public/js/components/models.js ================================================ import { h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import htm from "htm"; import { fetchEnvVars, saveEnvVars, fetchModels, fetchModelStatus, setPrimaryModel, fetchCodexStatus, disconnectCodex, exchangeCodexOAuth, } from "../lib/api.js"; import { showToast } from "./toast.js"; import { Badge } from "./badge.js"; import { SecretInput } from "./secret-input.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { ActionButton } from "./action-button.js"; import { getModelProvider, getAuthProviderFromModelProvider, getFeaturedModels, kProviderAuthFields, kProviderLabels, kProviderOrder, } from "../lib/model-config.js"; import { isCodexAuthCallbackMessage, openCodexAuthWindow, } from "../lib/codex-oauth-window.js"; const html = htm.bind(h); const getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || ""; const kAiCredentialKeys = Object.values(kProviderAuthFields) .flat() .map((field) => field.key) .filter((key, idx, arr) => arr.indexOf(key) === idx); let kModelsTabCache = null; export const Models = () => { const [envVars, setEnvVars] = useState(() => kModelsTabCache?.envVars || []); const [models, setModels] = useState(() => kModelsTabCache?.models || []); const [selectedModel, setSelectedModel] = useState(() => kModelsTabCache?.selectedModel || ""); const [showAllModels, setShowAllModels] = useState(() => kModelsTabCache?.showAllModels || false); const [savingChanges, setSavingChanges] = useState(false); const [codexStatus, setCodexStatus] = useState(() => kModelsTabCache?.codexStatus || { connected: false }); const [codexManualInput, setCodexManualInput] = useState(""); const [codexExchanging, setCodexExchanging] = useState(false); const [codexAuthStarted, setCodexAuthStarted] = useState(false); const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); const [modelsLoading, setModelsLoading] = useState(() => !kModelsTabCache); const [modelsError, setModelsError] = useState(() => kModelsTabCache?.modelsError || ""); const [ready, setReady] = useState(() => !!kModelsTabCache); const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || ""); const [modelDirty, setModelDirty] = useState(false); const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {}); const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { if (!ready) setModelsLoading(true); setModelsError(""); try { const [env, modelCatalog, modelStatus, codex] = await Promise.all([ fetchEnvVars(), fetchModels(), fetchModelStatus(), fetchCodexStatus(), ]); setEnvVars(env.vars || []); const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : []; setModels(catalogModels); const currentModel = modelStatus.modelKey || ""; setSelectedModel(currentModel); setCodexStatus(codex || { connected: false }); setSavedModel(currentModel); setModelDirty(false); const nextSavedAiValues = Object.fromEntries( kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]), ); setSavedAiValues(nextSavedAiValues); const nextModelsError = catalogModels.length ? "" : "No models found"; setModelsError(nextModelsError); kModelsTabCache = { envVars: env.vars || [], models: catalogModels, selectedModel: currentModel, savedModel: currentModel, savedAiValues: nextSavedAiValues, codexStatus: codex || { connected: false }, showAllModels, modelsError: nextModelsError, }; } catch (err) { setModelsError("Failed to load model settings"); showToast(`Failed to load model settings: ${err.message}`, "error"); } finally { setReady(true); setModelsLoading(false); } }; const refreshCodexConnection = async () => { try { const codex = await fetchCodexStatus(); setCodexStatus(codex || { connected: false }); if (codex?.connected) { setCodexAuthStarted(false); setCodexAuthWaiting(false); } kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: codex || { connected: false } }; } catch { setCodexStatus({ connected: false }); kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: { connected: false } }; } }; useEffect(() => { refresh(); }, []); useEffect(() => () => { if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; } }, []); const submitCodexAuthInput = async (input) => { const normalizedInput = String(input || "").trim(); if (!normalizedInput || codexExchangeInFlightRef.current) return; codexExchangeInFlightRef.current = true; setCodexManualInput(normalizedInput); setCodexExchanging(true); try { const result = await exchangeCodexOAuth(normalizedInput); if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); setCodexManualInput(""); showToast("Codex connected", "success"); setCodexAuthStarted(false); setCodexAuthWaiting(false); await refreshCodexConnection(); } catch (err) { setCodexAuthWaiting(false); showToast(err.message || "Codex OAuth exchange failed", "error"); } finally { codexExchangeInFlightRef.current = false; setCodexExchanging(false); } }; useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); } else if (isCodexAuthCallbackMessage(e.data)) { await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "error"); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { const existing = prev.some((entry) => entry.key === key); const next = existing ? prev.map((v) => (v.key === key ? { ...v, value } : v)) : [...prev, { key, value, editable: true }]; kModelsTabCache = { ...(kModelsTabCache || {}), envVars: next }; return next; }); }; const saveChanges = async () => { if (savingChanges) return; if (!modelDirty && !aiCredentialsDirty) return; if (modelDirty && !hasSelectedProviderAuth) { showToast("Add credentials for the selected model provider before saving model changes", "error"); return; } setSavingChanges(true); try { const targetModel = selectedModel; if (aiCredentialsDirty) { const payload = envVars .filter((v) => v.editable) .map((v) => ({ key: v.key, value: v.value })); const envResult = await saveEnvVars(payload); if (!envResult.ok) throw new Error(envResult.error || "Failed to save env vars"); } if (modelDirty && targetModel) { const modelResult = await setPrimaryModel(targetModel); if (!modelResult.ok) throw new Error(modelResult.error || "Failed to set primary model"); const status = await fetchModelStatus(); if (status?.ok === false) { throw new Error(status.error || "Failed to verify primary model"); } const activeModel = status?.modelKey || ""; if (activeModel && activeModel !== targetModel) { throw new Error(`Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`); } setSavedModel(targetModel); setModelDirty(false); kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: targetModel, savedModel: targetModel }; } showToast("Changes saved", "success"); await refresh(); } catch (err) { showToast(err.message || "Failed to save changes", "error"); } finally { setSavingChanges(false); } }; const startCodexAuth = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); return; } if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); } codexPopupPollRef.current = setInterval(() => { if (popup.closed) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; setCodexAuthWaiting(false); } }, 500); }; const completeCodexAuth = async () => { await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { const result = await disconnectCodex(); if (!result.ok) { showToast(result.error || "Failed to disconnect Codex", "error"); return; } showToast("Codex disconnected", "success"); setCodexAuthStarted(false); setCodexAuthWaiting(false); setCodexManualInput(""); await refreshCodexConnection(); }; const selectedModelProvider = getModelProvider(selectedModel); const selectedAuthProvider = getAuthProviderFromModelProvider(selectedModelProvider); const featuredModels = getFeaturedModels(models); const baseModelOptions = showAllModels ? models : featuredModels.length > 0 ? featuredModels : models; const selectedModelOption = models.find((model) => model.key === selectedModel); const modelOptions = selectedModelOption && !baseModelOptions.some((model) => model.key === selectedModelOption.key) ? [...baseModelOptions, selectedModelOption] : baseModelOptions; const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length; const primaryProvider = kProviderOrder.includes(selectedAuthProvider) ? selectedAuthProvider : kProviderOrder[0]; const otherProviders = kProviderOrder.filter((provider) => provider !== primaryProvider); const aiCredentialsDirty = kAiCredentialKeys.some( (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = selectedModelProvider === "openai-codex" ? !!codexStatus.connected : (kProviderAuthFields[selectedAuthProvider] || []).some((field) => Boolean(getKeyVal(envVars, field.key)), ); const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth)); const renderCredentialField = (field) => html`
<${SecretInput} value=${getKeyVal(envVars, field.key)} onInput=${(e) => setEnvValue(field.key, e.target.value)} placeholder=${field.placeholder || ""} isSecret=${!field.isText} inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" />

${field.hint}

`; const renderProviderContent = (provider) => { const fields = kProviderAuthFields[provider] || []; const hasCodex = provider === "openai"; return html` ${fields.map((field) => renderCredentialField(field))} ${hasCodex && html`
Codex OAuth ${codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
${codexAuthStarted ? html`

${codexAuthWaiting ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." : "Paste the redirect URL from your browser to finish connecting."}

` : codexStatus.connected ? html`
` : html` `} ${codexAuthStarted ? html`

After login, copy the full redirect URL (starts with http://localhost:1455/auth/callback) and paste it here.

setCodexManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> <${ActionButton} onClick=${completeCodexAuth} disabled=${!codexManualInput.trim() || codexExchanging} loading=${codexExchanging} tone="primary" size="sm" idleLabel="Complete Codex OAuth" loadingLabel="Completing..." className="text-xs font-medium px-3 py-1.5" /> ` : null}
`} `; }; if (!ready) { return html`
<${LoadingSpinner} className="h-4 w-4" /> Loading model settings...
`; } return html`

Primary Agent Model

${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}

${canToggleFullCatalog ? html`
` : null}
${renderProviderContent(primaryProvider)}

Other Providers

${otherProviders.map( (provider) => html`

${kProviderLabels[provider] || provider}

${renderProviderContent(provider)}
`, )}
<${ActionButton} onClick=${saveChanges} disabled=${!canSaveChanges} loading=${savingChanges} tone="primary" size="md" idleLabel="Save changes" loadingLabel="Saving..." className="w-full py-2.5 transition-all" /> ${modelDirty && !hasSelectedProviderAuth ? html`

Set credentials for the selected provider before saving this model change.

` : null}
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/browser-attach/index.js ================================================ import { h } from "preact"; import { useMemo } from "preact/hooks"; import htm from "htm"; import { marked } from "marked"; const html = htm.bind(h); const kReleaseNotesUrl = "https://github.com/openclaw/openclaw/releases/tag/v2026.3.13"; const kSetupInstructionsMarkdown = `Release reference: [OpenClaw 2026.3.13](${kReleaseNotesUrl}) ## Requirements - OpenClaw 2026.3.13+ - Chrome 144+ - Node.js installed on the Mac node so \`npx\` is available ## Setup ### 1) Enable remote debugging in Chrome Open \`chrome://inspect/#remote-debugging\` and turn it on. Do **not** launch Chrome with \`--remote-debugging-port\`. ### 2) Configure the node In \`~/.openclaw/openclaw.json\` on the Mac node: \`\`\`json { "browser": { "defaultProfile": "user" } } \`\`\` The built-in \`user\` profile uses live Chrome attach. You do not need a custom \`existing-session\` profile. ### 3) Approve Chrome consent On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. ## Troubleshooting | Problem | Fix | | --- | --- | | Browser proxy times out (20s) | Restart Chrome cleanly and run the check again. | | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. | | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. | | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. | | \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`; export const BrowserAttachCard = () => { const setupInstructionsHtml = useMemo( () => marked.parse(kSetupInstructionsMarkdown, { gfm: true, breaks: true, }), [], ); return html`
Chrome debugging setup / troubleshooting
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/connected-nodes/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../../action-button.js"; import { Badge } from "../../badge.js"; import { ConfirmDialog } from "../../confirm-dialog.js"; import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js"; import { LoadingSpinner } from "../../loading-spinner.js"; import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js"; import { useConnectedNodesCard } from "./use-connected-nodes-card.js"; const html = htm.bind(h); const escapeDoubleQuotes = (value) => String(value || "").replace(/"/g, '\\"'); const buildReconnectCommand = ({ node, connectInfo, maskToken = false }) => { const host = String(connectInfo?.gatewayHost || "").trim() || "localhost"; const port = Number(connectInfo?.gatewayPort) || 3000; const token = String(connectInfo?.gatewayToken || "").trim(); const tlsFlag = connectInfo?.tls === true ? "--tls" : ""; const displayName = String( node?.displayName || node?.nodeId || "My Node", ).trim(); const tokenValue = maskToken ? "****" : token; return [ tokenValue ? `OPENCLAW_GATEWAY_TOKEN=${tokenValue}` : "", "openclaw node run", `--host ${host}`, `--port ${port}`, tlsFlag, `--display-name "${escapeDoubleQuotes(displayName)}"`, ] .filter(Boolean) .join(" "); }; const renderNodeStatusBadge = (node) => { if (node?.connected) { return html`<${Badge} tone="success">Connected`; } if (node?.paired) { return html`<${Badge} tone="warning">Disconnected`; } return html`<${Badge} tone="danger">Pending approval`; }; const isBrowserCapableNode = (node) => { const caps = Array.isArray(node?.caps) ? node.caps : []; const commands = Array.isArray(node?.commands) ? node.commands : []; return caps.includes("browser") || commands.includes("browser.proxy"); }; const getBrowserStatusTone = (status) => { if (status.running) return "success"; return "warning"; }; const getBrowserStatusLabel = (status) => { if (status.running) return "Attached"; return "Not connected"; }; export const ConnectedNodesCard = ({ nodes = [], pending = [], loading = false, error = "", connectInfo = null, onRefreshNodes = async () => {}, }) => { const state = useConnectedNodesCard({ nodes, onRefreshNodes }); const { browserStatusByNodeId, browserErrorByNodeId, checkingBrowserNodeId, browserAttachStateByNodeId, menuOpenNodeId, removeDialogNode, removingNodeId, handleCopyText, handleCheckNodeBrowser, handleAttachNodeBrowser, handleDetachNodeBrowser, handleOpenNodeMenu, handleRemoveNode, setMenuOpenNodeId, setRemoveDialogNode, } = state; return html`
${pending.length ? html`
${pending.length} pending node${pending.length === 1 ? "" : "s"} waiting for approval.
` : null} ${loading ? html`
<${LoadingSpinner} className="h-4 w-4" /> Loading nodes...
` : error ? html`
${error}
` : !nodes.length ? html`
<${ComputerLineIcon} className="h-12 w-12 text-cyan-400" />

No connected nodes yet

Connect a Mac, iOS, Android, or headless node to run system and browser commands through this gateway.

` : html`
${nodes.map((node) => { const nodeId = String(node?.nodeId || "").trim(); const browserStatus = browserStatusByNodeId[nodeId] || null; const browserError = browserErrorByNodeId[nodeId] || ""; const checkingBrowser = checkingBrowserNodeId === nodeId; const canCheckBrowser = node?.connected && isBrowserCapableNode(node) && nodeId; const browserAttachEnabled = browserAttachStateByNodeId?.[nodeId] === true; const hasBrowserCheckResult = !!browserStatus || !!browserError; const browserAttached = browserStatus?.running === true; const showResolvingSpinner = browserAttachEnabled && !hasBrowserCheckResult && !checkingBrowser; const showBrowserCheckButton = canCheckBrowser && browserAttachEnabled && !checkingBrowser && hasBrowserCheckResult && !browserAttached; return html`
${node?.displayName || node?.nodeId || "Unnamed node"}
${nodeId ? html` ` : null}
${renderNodeStatusBadge(node)} ${node?.paired ? html` <${OverflowMenu} open=${menuOpenNodeId === nodeId} ariaLabel="Open node actions" title="Open node actions" onClose=${() => setMenuOpenNodeId("")} onToggle=${() => handleOpenNodeMenu(nodeId)} > <${OverflowMenuItem} className="text-status-error hover:text-status-error" onClick=${() => { setMenuOpenNodeId(""); setRemoveDialogNode(node); }} > Remove device ` : null}
platform: ${node?.platform || "unknown"}
version: ${node?.version || "unknown"}
capabilities: ${Array.isArray(node?.caps) ? node.caps.join(", ") : "none"}
${canCheckBrowser ? html`
Browser
${browserAttachEnabled ? html`
profile: user
` : html`
Attach is disabled until you click ${" "} Attach ${" "} (prevents control prompts when opening this tab).
`}
${browserStatus ? html` <${Badge} tone=${getBrowserStatusTone(browserStatus)} >${getBrowserStatusLabel(browserStatus)} ` : null} ${showResolvingSpinner ? html` <${LoadingSpinner} className="h-3.5 w-3.5" /> ` : null} ${checkingBrowser ? html` <${LoadingSpinner} className="h-3.5 w-3.5" /> ` : null} ${canCheckBrowser && !browserAttachEnabled ? html` <${ActionButton} onClick=${() => handleAttachNodeBrowser(nodeId)} idleLabel="Attach" tone="primary" size="sm" /> ` : null} ${showBrowserCheckButton ? html` <${ActionButton} onClick=${() => handleCheckNodeBrowser(nodeId)} idleLabel="Check" tone="secondary" size="sm" /> ` : null}
${browserStatus ? html`
driver: ${browserStatus?.driver || "unknown"} transport: ${browserStatus?.transport || "unknown"}
` : null} ${browserError ? html`
${browserError}
` : null} ${canCheckBrowser && browserAttachEnabled && !checkingBrowser ? html`
` : null}
` : null} ${node?.paired && !node?.connected && connectInfo ? html`
Reconnect command
<${ActionButton} onClick=${() => handleCopyText( buildReconnectCommand({ node, connectInfo, maskToken: false, }), { successMessage: "Connection command copied", errorMessage: "Could not copy connection command", }, )} tone="secondary" size="sm" iconOnly=${true} idleIcon=${FileCopyLineIcon} idleIconClassName="w-3.5 h-3.5" ariaLabel="Copy reconnect command" title="Copy reconnect command" />
` : null}
`; })}
`}
<${ConfirmDialog} visible=${!!removeDialogNode} title="Remove device?" message=${removeDialogNode?.connected ? "This device is currently connected. Removing it will disconnect and remove the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later." : "This removes the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."} confirmLabel="Remove device" confirmLoadingLabel="Removing..." confirmTone="warning" confirmLoading=${Boolean(removingNodeId)} confirmDisabled=${Boolean(removingNodeId)} onCancel=${() => { if (removingNodeId) return; setRemoveDialogNode(null); }} onConfirm=${handleRemoveNode} /> `; }; ================================================ FILE: lib/public/js/components/nodes-tab/connected-nodes/use-connected-nodes-card.js ================================================ import { useCallback, useEffect, useRef, useState, } from "preact/hooks"; import { copyTextToClipboard } from "../../../lib/clipboard.js"; import { fetchNodeBrowserStatusForNode, removeNode } from "../../../lib/api.js"; import { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js"; import { showToast } from "../../toast.js"; const kBrowserCheckTimeoutMs = 35000; const kBrowserPollIntervalMs = 10000; const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode"; const kBrowserClosedPageErrorPattern = /selected page has been closed/i; const withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => { let timeoutId = null; try { return await Promise.race([ promise, new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error("Browser check timed out")); }, timeoutMs); }), ]); } finally { if (timeoutId) { clearTimeout(timeoutId); } } }; const isBrowserCapableNode = (node) => { const caps = Array.isArray(node?.caps) ? node.caps : []; const commands = Array.isArray(node?.commands) ? node.commands : []; return caps.includes("browser") || commands.includes("browser.proxy"); }; const normalizeBrowserStatusError = (error) => { const rawMessage = String( error?.message || "Could not check node browser status", ).trim(); if (kBrowserClosedPageErrorPattern.test(rawMessage)) { return "Selected Chrome page was closed. Click Attach to reconnect."; } return rawMessage; }; const readBrowserAttachStateByNode = () => { const uiSettings = readUiSettings(); const attachState = uiSettings?.[kBrowserAttachStateByNodeKey]; if ( !attachState || typeof attachState !== "object" || Array.isArray(attachState) ) { return {}; } return attachState; }; const writeBrowserAttachStateByNode = (nextState = {}) => { updateUiSettings((currentSettings) => { const nextSettings = currentSettings && typeof currentSettings === "object" ? currentSettings : {}; return { ...nextSettings, [kBrowserAttachStateByNodeKey]: nextState && typeof nextState === "object" ? nextState : {}, }; }); }; export const useConnectedNodesCard = ({ nodes = [], onRefreshNodes = async () => {}, } = {}) => { const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({}); const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({}); const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState(""); const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState( () => readBrowserAttachStateByNode(), ); const [menuOpenNodeId, setMenuOpenNodeId] = useState(""); const [removeDialogNode, setRemoveDialogNode] = useState(null); const [removingNodeId, setRemovingNodeId] = useState(""); const browserPollCursorRef = useRef(0); const browserCheckInFlightNodeIdRef = useRef(""); const handleCopyText = async ( text, { successMessage = "Connection command copied", errorMessage = "Could not copy connection command", } = {}, ) => { const copied = await copyTextToClipboard(text); if (copied) { showToast(successMessage, "success"); return; } showToast(errorMessage, "error"); }; const handleCheckNodeBrowser = useCallback( async (nodeId, { silent = false } = {}) => { const normalizedNodeId = String(nodeId || "").trim(); if (!normalizedNodeId || browserCheckInFlightNodeIdRef.current) return; browserCheckInFlightNodeIdRef.current = normalizedNodeId; if (!silent) { setCheckingBrowserNodeId(normalizedNodeId); } setBrowserErrorByNodeId((prev) => ({ ...prev, [normalizedNodeId]: "", })); try { const result = await withTimeout( fetchNodeBrowserStatusForNode(normalizedNodeId, "user"), ); const status = result?.status && typeof result.status === "object" ? result.status : null; setBrowserStatusByNodeId((prev) => ({ ...prev, [normalizedNodeId]: status, })); } catch (error) { const message = normalizeBrowserStatusError(error); // Stop poll loops after failures so we do not keep retrying a stale browser session. setBrowserStatusByNodeId((prev) => ({ ...prev, [normalizedNodeId]: null, })); setBrowserErrorByNodeId((prev) => ({ ...prev, [normalizedNodeId]: message, })); if (!silent) { showToast(message, "error"); } } finally { browserCheckInFlightNodeIdRef.current = ""; if (!silent) { setCheckingBrowserNodeId(""); } } }, [], ); const setBrowserAttachStateForNode = useCallback((nodeId, enabled) => { const normalizedNodeId = String(nodeId || "").trim(); if (!normalizedNodeId) return; setBrowserAttachStateByNodeId((prevState) => { const nextState = { ...(prevState && typeof prevState === "object" ? prevState : {}), [normalizedNodeId]: enabled === true, }; writeBrowserAttachStateByNode(nextState); return nextState; }); }, []); const handleAttachNodeBrowser = useCallback( async (nodeId) => { const normalizedNodeId = String(nodeId || "").trim(); if (!normalizedNodeId) return; setBrowserAttachStateForNode(normalizedNodeId, true); await handleCheckNodeBrowser(normalizedNodeId); }, [handleCheckNodeBrowser, setBrowserAttachStateForNode], ); const handleDetachNodeBrowser = useCallback( (nodeId) => { const normalizedNodeId = String(nodeId || "").trim(); if (!normalizedNodeId) return; setBrowserAttachStateForNode(normalizedNodeId, false); setBrowserStatusByNodeId((prevState) => { const nextState = { ...(prevState || {}) }; delete nextState[normalizedNodeId]; return nextState; }); setBrowserErrorByNodeId((prevState) => { const nextState = { ...(prevState || {}) }; delete nextState[normalizedNodeId]; return nextState; }); }, [setBrowserAttachStateForNode], ); const handleOpenNodeMenu = useCallback((nodeId) => { const normalizedNodeId = String(nodeId || "").trim(); if (!normalizedNodeId) return; setMenuOpenNodeId((currentNodeId) => currentNodeId === normalizedNodeId ? "" : normalizedNodeId, ); }, []); const handleRemoveNode = useCallback(async () => { const nodeId = String(removeDialogNode?.nodeId || "").trim(); if (!nodeId || removingNodeId) return; setRemovingNodeId(nodeId); try { await removeNode(nodeId); // Removing a device should also clear local browser-attach state for that node. handleDetachNodeBrowser(nodeId); showToast("Device removed", "success"); setRemoveDialogNode(null); setMenuOpenNodeId(""); await onRefreshNodes(); } catch (removeError) { showToast(removeError.message || "Could not remove node", "error"); } finally { setRemovingNodeId(""); } }, [ handleDetachNodeBrowser, onRefreshNodes, removeDialogNode, removingNodeId, ]); useEffect(() => { if (checkingBrowserNodeId) return; const pendingInitialNodeId = nodes .map((node) => ({ nodeId: String(node?.nodeId || "").trim(), connected: node?.connected === true, browserCapable: isBrowserCapableNode(node), })) .find((entry) => { if (!entry.nodeId || !entry.connected || !entry.browserCapable) return false; if (browserAttachStateByNodeId?.[entry.nodeId] !== true) return false; if (browserStatusByNodeId?.[entry.nodeId]) return false; if (browserErrorByNodeId?.[entry.nodeId]) return false; return true; })?.nodeId; if (!pendingInitialNodeId) return; handleCheckNodeBrowser(pendingInitialNodeId, { silent: true }); }, [ browserAttachStateByNodeId, browserErrorByNodeId, browserStatusByNodeId, checkingBrowserNodeId, handleCheckNodeBrowser, nodes, ]); useEffect(() => { if (checkingBrowserNodeId) return; const pollableNodeIds = nodes .map((node) => ({ nodeId: String(node?.nodeId || "").trim(), connected: node?.connected === true, browserCapable: isBrowserCapableNode(node), browserRunning: browserStatusByNodeId?.[String(node?.nodeId || "").trim()]?.running === true, })) .filter( (entry) => entry.nodeId && entry.connected && entry.browserCapable && browserAttachStateByNodeId?.[entry.nodeId] === true && entry.browserRunning, ) .map((entry) => entry.nodeId); if (!pollableNodeIds.length) return; let active = true; const poll = async () => { if (!active || browserCheckInFlightNodeIdRef.current) return; const pollIndex = browserPollCursorRef.current % pollableNodeIds.length; browserPollCursorRef.current += 1; const nextNodeId = pollableNodeIds[pollIndex]; await handleCheckNodeBrowser(nextNodeId, { silent: true }); }; const timer = setInterval(poll, kBrowserPollIntervalMs); return () => { active = false; clearInterval(timer); }; }, [ browserAttachStateByNodeId, browserStatusByNodeId, checkingBrowserNodeId, handleCheckNodeBrowser, nodes, ]); return { browserStatusByNodeId, browserErrorByNodeId, checkingBrowserNodeId, browserAttachStateByNodeId, menuOpenNodeId, removeDialogNode, removingNodeId, handleCopyText, handleCheckNodeBrowser, handleAttachNodeBrowser, handleDetachNodeBrowser, handleOpenNodeMenu, handleRemoveNode, setMenuOpenNodeId, setRemoveDialogNode, }; }; ================================================ FILE: lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js ================================================ import { usePolling } from "../../../hooks/usePolling.js"; import { fetchNodesStatus } from "../../../lib/api.js"; const kNodesPollIntervalMs = 10000; export const useConnectedNodes = ({ enabled = true } = {}) => { const poll = usePolling( async () => { const result = await fetchNodesStatus(); const nodes = Array.isArray(result?.nodes) ? result.nodes : []; const pending = Array.isArray(result?.pending) ? result.pending : []; return { nodes, pending }; }, kNodesPollIntervalMs, { enabled, cacheKey: "/api/nodes", dedupeInFlight: true }, ); return { nodes: Array.isArray(poll.data?.nodes) ? poll.data.nodes : [], pending: Array.isArray(poll.data?.pending) ? poll.data.pending : [], loading: poll.data === null && !poll.error, error: poll.error ? String(poll.error.message || "Could not load nodes") : "", refresh: poll.refresh, }; }; ================================================ FILE: lib/public/js/components/nodes-tab/exec-allowlist/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../../action-button.js"; import { useExecAllowlist } from "./use-exec-allowlist.js"; const html = htm.bind(h); export const NodeExecAllowlistCard = () => { const state = useExecAllowlist(); return html`

Gateway Exec Allowlist

Patterns here are used when tools.exec.security is set to allowlist.

<${ActionButton} onClick=${state.refresh} idleLabel="Reload" tone="secondary" size="sm" disabled=${state.loading} />
${state.error ? html`
${state.error}
` : null}
state.setPatternInput(event.target.value)} placeholder="/usr/bin/sw_vers" class="flex-1 min-w-0 bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none" disabled=${state.loading || state.saving} /> <${ActionButton} onClick=${state.addPattern} loading=${state.saving} idleLabel="Add Pattern" loadingLabel="Adding..." tone="primary" size="sm" disabled=${!String(state.patternInput || "").trim()} />
Supports wildcard patterns like *, **, and exact executable paths.
${state.loading ? html`
Loading allowlist...
` : !state.allowlist.length ? html`
No allowlist patterns configured.
` : html`
${state.allowlist.map( (entry) => html`
${entry?.pattern || ""}
${entry?.id || ""}
<${ActionButton} onClick=${() => state.removePattern(entry?.id)} loading=${state.removingId === String(entry?.id || "")} idleLabel="Remove" loadingLabel="Removing..." tone="danger" size="sm" />
`, )}
`}
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js ================================================ import { useCallback, useEffect, useState } from "preact/hooks"; import { addNodeExecAllowlistPattern, fetchNodeExecApprovals, removeNodeExecAllowlistPattern, } from "../../../lib/api.js"; import { showToast } from "../../toast.js"; export const useExecAllowlist = () => { const [allowlist, setAllowlist] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [patternInput, setPatternInput] = useState(""); const [saving, setSaving] = useState(false); const [removingId, setRemovingId] = useState(""); const refresh = useCallback(async () => { setLoading(true); setError(""); try { const result = await fetchNodeExecApprovals(); const nextAllowlist = Array.isArray(result?.allowlist) ? result.allowlist : []; setAllowlist(nextAllowlist); } catch (err) { setError(err.message || "Could not load allowlist"); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const addPattern = useCallback(async () => { const nextPattern = String(patternInput || "").trim(); if (!nextPattern || saving) return; setSaving(true); try { await addNodeExecAllowlistPattern(nextPattern); setPatternInput(""); showToast("Allowlist pattern added", "success"); await refresh(); } catch (err) { showToast(err.message || "Could not add allowlist pattern", "error"); } finally { setSaving(false); } }, [patternInput, refresh, saving]); const removePattern = useCallback(async (entryId) => { const id = String(entryId || "").trim(); if (!id || removingId) return; setRemovingId(id); try { await removeNodeExecAllowlistPattern(id); showToast("Allowlist pattern removed", "success"); await refresh(); } catch (err) { showToast(err.message || "Could not remove allowlist pattern", "error"); } finally { setRemovingId(""); } }, [refresh, removingId]); return { allowlist, loading, error, patternInput, saving, removingId, setPatternInput, refresh, addPattern, removePattern, }; }; ================================================ FILE: lib/public/js/components/nodes-tab/exec-config/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ActionButton } from "../../action-button.js"; import { useExecConfig } from "./use-exec-config.js"; const html = htm.bind(h); export const NodeExecConfigCard = ({ nodes = [], onRestartRequired = () => {}, }) => { const state = useExecConfig({ onRestartRequired }); const availableNodeOptions = nodes .filter((node) => String(node?.nodeId || "").trim()) .map((node) => ({ value: String(node.nodeId).trim(), label: String(node?.displayName || node.nodeId).trim(), })); return html`

Exec Routing

Set where command execution runs and how strict approval policy should be.

<${ActionButton} onClick=${state.refresh} idleLabel="Reload" tone="secondary" size="sm" disabled=${state.loading} />
${state.error ? html`
${state.error}
` : null}
Save applies config immediately, but gateway restart may still be required by OpenClaw.
<${ActionButton} onClick=${state.save} loading=${state.saving} idleLabel="Save Exec Config" loadingLabel="Saving..." tone="primary" size="sm" disabled=${state.loading || (state.config.host === "node" && !state.config.node)} />
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/exec-config/use-exec-config.js ================================================ import { useCallback, useEffect, useState } from "preact/hooks"; import { fetchNodeExecConfig, saveNodeExecConfig } from "../../../lib/api.js"; import { showToast } from "../../toast.js"; const kDefaultExecConfig = { host: "gateway", security: "allowlist", ask: "on-miss", node: "", }; export const useExecConfig = ({ onRestartRequired = () => {} } = {}) => { const [config, setConfig] = useState(kDefaultExecConfig); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const refresh = useCallback(async () => { setLoading(true); setError(""); try { const result = await fetchNodeExecConfig(); const nextConfig = { ...kDefaultExecConfig, ...(result?.config || {}), }; setConfig(nextConfig); } catch (err) { setError(err.message || "Could not load exec settings"); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const updateField = useCallback((field, value) => { setConfig((prev) => { const next = { ...prev, [field]: value }; if (field === "host" && value !== "node") { next.node = ""; } return next; }); }, []); const save = useCallback(async () => { if (saving) return false; setSaving(true); setError(""); try { const result = await saveNodeExecConfig(config); if (result?.restartRequired) { onRestartRequired(true); } showToast("Node exec config saved", "success"); return true; } catch (err) { const message = err.message || "Could not save exec settings"; setError(message); showToast(message, "error"); return false; } finally { setSaving(false); } }, [config, onRestartRequired, saving]); return { config, loading, saving, error, refresh, updateField, save, }; }; ================================================ FILE: lib/public/js/components/nodes-tab/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { PageHeader } from "../page-header.js"; import { ActionButton } from "../action-button.js"; import { useNodesTab } from "./use-nodes-tab.js"; import { ConnectedNodesCard } from "./connected-nodes/index.js"; import { BrowserAttachCard } from "./browser-attach/index.js"; import { NodesSetupWizard } from "./setup-wizard/index.js"; const html = htm.bind(h); export const NodesTab = ({ onRestartRequired = () => {} }) => { const { state, actions } = useNodesTab(); return html`
<${PageHeader} title="Nodes" actions=${html` <${ActionButton} onClick=${actions.refreshNodes} loading=${state.refreshingNodes} loadingMode="inline" idleLabel="Refresh" tone="secondary" size="sm" /> <${ActionButton} onClick=${actions.openWizard} idleLabel="Connect Node" tone="primary" size="sm" /> `} /> <${ConnectedNodesCard} nodes=${state.nodes} pending=${state.pending} loading=${state.loadingNodes} error=${state.nodesError} connectInfo=${state.connectInfo} onRefreshNodes=${actions.refreshNodes} /> <${BrowserAttachCard} /> <${NodesSetupWizard} visible=${state.wizardVisible} nodes=${state.nodes} refreshNodes=${actions.refreshNodes} onRestartRequired=${onRestartRequired} onClose=${actions.closeWizard} />
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/setup-wizard/index.js ================================================ import { h } from "preact"; import htm from "htm"; import { ModalShell } from "../../modal-shell.js"; import { ActionButton } from "../../action-button.js"; import { CloseIcon, FileCopyLineIcon } from "../../icons.js"; import { DevicePairings } from "../../device-pairings.js"; import { copyTextToClipboard } from "../../../lib/clipboard.js"; import { showToast } from "../../toast.js"; import { useSetupWizard } from "./use-setup-wizard.js"; const html = htm.bind(h); const kWizardSteps = ["Install OpenClaw CLI", "Connect Node"]; const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
${command}
`; const copyAndToast = async (value, label = "text") => { const copied = await copyTextToClipboard(value); if (copied) { showToast("Copied to clipboard", "success"); return; } showToast(`Could not copy ${label}`, "error"); }; export const NodesSetupWizard = ({ visible = false, nodes = [], refreshNodes = async () => {}, onRestartRequired = () => {}, onClose = () => {}, }) => { const state = useSetupWizard({ visible, nodes, refreshNodes, onRestartRequired, onClose, }); const isFinalStep = state.step === kWizardSteps.length - 1; return html` <${ModalShell} visible=${visible} onClose=${onClose} closeOnOverlayClick=${false} closeOnEscape=${false} panelClassName="relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4" >
Node Setup Wizard
${kWizardSteps.map( (_label, idx) => html`
`, )}

Step ${state.step + 1} of ${kWizardSteps.length}: ${kWizardSteps[state.step]}

${ state.step === 0 ? html`
Install OpenClaw on the machine you want to connect as a node.
${renderCommandBlock({ command: "npm install -g openclaw", onCopy: () => copyAndToast("npm install -g openclaw", "command"), })}
Requires Node.js 22+.
` : null } ${ state.step === 1 ? html`
Run this on the device you want to connect:
${state.loadingConnectInfo ? html`
Loading command...
` : renderCommandBlock({ command: state.connectCommand || "Could not build connect command.", onCopy: () => copyAndToast(state.connectCommand || "", "command"), })}
${state.devicePending.length ? html` <${DevicePairings} pending=${state.devicePending} onApprove=${state.handleDeviceApprove} onReject=${state.handleDeviceReject} /> ` : state.selectedPairedNode && !state.selectedPairedNode.connected ? html`
Node is paired but currently disconnected. Run the node command again on your device, then Finish will enable.
` : html`
Pairing request will show up here. Checks every 3s.
`}
` : null }
${ state.step === 0 ? html`
` : html` <${ActionButton} onClick=${() => state.setStep(Math.max(0, state.step - 1))} idleLabel="Back" tone="secondary" size="md" className="w-full justify-center" /> ` } ${ isFinalStep ? html` <${ActionButton} onClick=${async () => { const ok = await state.applyGatewayNodeRouting(); if (!ok) return; state.completeWizard(); Promise.resolve(refreshNodes()).catch(() => {}); }} loading=${state.configuring} idleLabel=${state.canFinish ? "Finish" : "Awaiting pairing"} loadingLabel="Finishing..." tone="primary" size="md" className="w-full justify-center" disabled=${!state.canFinish} /> ` : html` <${ActionButton} onClick=${() => state.setStep( Math.min(kWizardSteps.length - 1, state.step + 1), )} idleLabel="Next" tone="primary" size="md" className="w-full justify-center" /> ` }
`; }; ================================================ FILE: lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js ================================================ import { useCallback, useEffect, useMemo, useRef, useState, } from "preact/hooks"; import { approveDevice, fetchDevicePairings, fetchNodeConnectInfo, rejectDevice, routeExecToNode, } from "../../../lib/api.js"; import { showToast } from "../../toast.js"; const kNodeDiscoveryPollIntervalMs = 3000; export const useSetupWizard = ({ visible = false, nodes = [], refreshNodes = async () => {}, onRestartRequired = () => {}, onClose = () => {}, } = {}) => { const [step, setStep] = useState(0); const [connectInfo, setConnectInfo] = useState(null); const [loadingConnectInfo, setLoadingConnectInfo] = useState(false); const [displayName, setDisplayName] = useState("My Mac Node"); const [selectedNodeId, setSelectedNodeId] = useState(""); const [configuring, setConfiguring] = useState(false); const [devicePending, setDevicePending] = useState([]); const [approvedInSession, setApprovedInSession] = useState(false); const refreshInFlightRef = useRef(false); useEffect(() => { if (!visible) return; setStep(0); setSelectedNodeId(""); setConfiguring(false); setApprovedInSession(false); }, [visible]); useEffect(() => { if (!visible) return; setLoadingConnectInfo(true); fetchNodeConnectInfo() .then((result) => { setConnectInfo(result || null); }) .catch((err) => { showToast(err.message || "Could not load node connect command", "error"); }) .finally(() => { setLoadingConnectInfo(false); }); }, [visible]); const pairedNodes = useMemo(() => { const seen = new Set(); const unique = []; for (const entry of nodes) { const nodeId = String(entry?.nodeId || "").trim(); if (!nodeId || seen.has(nodeId)) continue; if (entry?.paired === false) continue; seen.add(nodeId); unique.push({ nodeId, displayName: String(entry?.displayName || entry?.name || nodeId), connected: entry?.connected === true, }); } return unique; }, [nodes]); const selectedPairedNode = useMemo( () => pairedNodes.find( (entry) => entry.nodeId === String(selectedNodeId || "").trim(), ) || null, [pairedNodes, selectedNodeId], ); const connectCommand = useMemo(() => { if (!connectInfo) return ""; const host = String(connectInfo.gatewayHost || "").trim() || "localhost"; const port = Number(connectInfo.gatewayPort) || 3000; const token = String(connectInfo.gatewayToken || "").trim(); const tls = connectInfo.tls === true ? " --tls" : ""; const escapedDisplayName = String(displayName || "") .trim() .replace(/"/g, '\\"'); return [ token ? `OPENCLAW_GATEWAY_TOKEN=${token}` : "", "openclaw node run", `--host ${host}`, `--port ${port}`, tls.trim(), escapedDisplayName ? `--display-name "${escapedDisplayName}"` : "", ] .filter(Boolean) .join(" "); }, [connectInfo, displayName]); const refreshNodeList = useCallback(async () => { if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; try { await refreshNodes(); const deviceData = await fetchDevicePairings(); const pendingList = Array.isArray(deviceData?.pending) ? deviceData.pending : []; setDevicePending(pendingList); } finally { refreshInFlightRef.current = false; } }, [refreshNodes]); useEffect(() => { if (!visible || step !== 1) return; let active = true; const poll = async () => { if (!active) return; try { await refreshNodeList(); } catch {} }; poll(); const timer = setInterval(poll, kNodeDiscoveryPollIntervalMs); return () => { active = false; clearInterval(timer); }; }, [refreshNodeList, step, visible]); useEffect(() => { if (!visible || step !== 1) return; const hasSelected = pairedNodes.some( (entry) => entry.nodeId === String(selectedNodeId || "").trim(), ); const normalizedDisplayName = String(displayName || "").trim().toLowerCase(); const preferredNode = pairedNodes.find( (entry) => String(entry?.displayName || "") .trim() .toLowerCase() === normalizedDisplayName, ) || pairedNodes[0]; if (!preferredNode) return; if (hasSelected && String(selectedNodeId || "").trim() === preferredNode.nodeId) return; setSelectedNodeId(preferredNode.nodeId); }, [displayName, pairedNodes, selectedNodeId, step, visible]); const handleDeviceApprove = useCallback(async (requestId) => { try { await approveDevice(requestId); showToast("Pairing approved", "success"); setApprovedInSession(true); await refreshNodeList(); } catch (err) { showToast(err.message || "Could not approve pairing", "error"); } }, [refreshNodeList]); const handleDeviceReject = useCallback(async (requestId) => { try { await rejectDevice(requestId); showToast("Pairing rejected", "info"); await refreshNodeList(); } catch (err) { showToast(err.message || "Could not reject pairing", "error"); } }, [refreshNodeList]); const applyGatewayNodeRouting = useCallback(async () => { const nodeId = String(selectedNodeId || "").trim(); if (!nodeId || configuring) return false; setConfiguring(true); try { await routeExecToNode(nodeId); onRestartRequired(true); showToast("Gateway routing now points to the selected node", "success"); return true; } catch (err) { showToast(err.message || "Could not configure gateway node routing", "error"); return false; } finally { setConfiguring(false); } }, [configuring, onRestartRequired, selectedNodeId]); const completeWizard = useCallback(() => { onClose(); }, [onClose]); return { step, setStep, connectInfo, loadingConnectInfo, displayName, setDisplayName, selectedNodeId, setSelectedNodeId, pairedNodes, selectedPairedNode, devicePending, approvedInSession, configuring, canFinish: approvedInSession && Boolean(selectedPairedNode?.connected), connectCommand, refreshNodeList, nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs, handleDeviceApprove, handleDeviceReject, applyGatewayNodeRouting, completeWizard, }; }; ================================================ FILE: lib/public/js/components/nodes-tab/use-nodes-tab.js ================================================ import { useCallback, useEffect, useState } from "preact/hooks"; import { fetchNodeConnectInfo } from "../../lib/api.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; import { showToast } from "../toast.js"; import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js"; export const useNodesTab = () => { const connectedNodesState = useConnectedNodes({ enabled: true }); const [wizardVisible, setWizardVisible] = useState(false); const [refreshingNodes, setRefreshingNodes] = useState(false); const { data: connectInfo, error: connectInfoError, } = useCachedFetch("/api/nodes/connect-info", fetchNodeConnectInfo, { maxAgeMs: 60000, }); const pairedNodes = Array.isArray(connectedNodesState.nodes) ? connectedNodesState.nodes.filter((entry) => entry?.paired !== false) : []; useEffect(() => { if (!connectInfoError) return; showToast( connectInfoError.message || "Could not load node connect command", "error", ); }, [connectInfoError]); const refreshNodes = useCallback(async () => { if (refreshingNodes) return; setRefreshingNodes(true); try { await connectedNodesState.refresh(); } finally { setRefreshingNodes(false); } }, [connectedNodesState.refresh, refreshingNodes]); return { state: { wizardVisible, nodes: pairedNodes, pending: connectedNodesState.pending, loadingNodes: connectedNodesState.loading, refreshingNodes, nodesError: connectedNodesState.error, connectInfo, }, actions: { openWizard: () => setWizardVisible(true), closeWizard: () => setWizardVisible(false), refreshNodes, }, }; }; ================================================ FILE: lib/public/js/components/onboarding/pairing-utils.js ================================================ export const getPreferredPairingChannel = (vals = {}) => { if (vals.TELEGRAM_BOT_TOKEN) return "telegram"; if (vals.DISCORD_BOT_TOKEN) return "discord"; if (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN) return "slack"; return ""; }; export const isChannelPaired = (channels = {}, channel = "") => { if (!channel) return false; const info = channels?.[channel]; if (!info) return false; return info.status === "paired" && Number(info.paired || 0) > 0; }; ================================================ FILE: lib/public/js/components/onboarding/use-welcome-codex.js ================================================ import { useEffect, useRef, useState } from "preact/hooks"; import { disconnectCodex, exchangeCodexOAuth, fetchCodexStatus, } from "../../lib/api.js"; import { isCodexAuthCallbackMessage, openCodexAuthWindow, } from "../../lib/codex-oauth-window.js"; export const useWelcomeCodex = ({ setFormError } = {}) => { const [codexStatus, setCodexStatus] = useState({ connected: false }); const [codexLoading, setCodexLoading] = useState(true); const [codexManualInput, setCodexManualInput] = useState(""); const [codexExchanging, setCodexExchanging] = useState(false); const [codexAuthStarted, setCodexAuthStarted] = useState(false); const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refreshCodexStatus = async () => { try { const status = await fetchCodexStatus(); setCodexStatus(status); if (status?.connected) { setCodexAuthStarted(false); setCodexAuthWaiting(false); } } catch { setCodexStatus({ connected: false }); } finally { setCodexLoading(false); } }; useEffect(() => { refreshCodexStatus(); }, []); const submitCodexAuthInput = async (input) => { const normalizedInput = String(input || "").trim(); if (!normalizedInput || codexExchangeInFlightRef.current) return; codexExchangeInFlightRef.current = true; setCodexManualInput(normalizedInput); setCodexExchanging(true); setFormError(null); try { const result = await exchangeCodexOAuth(normalizedInput); if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); setCodexManualInput(""); setCodexAuthStarted(false); setCodexAuthWaiting(false); await refreshCodexStatus(); } catch (err) { setCodexAuthWaiting(false); setFormError(err.message || "Codex OAuth exchange failed"); } finally { codexExchangeInFlightRef.current = false; setCodexExchanging(false); } }; useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { await refreshCodexStatus(); } else if (isCodexAuthCallbackMessage(e.data)) { await submitCodexAuthInput(e.data.input); } if (e.data?.codex === "error") { setFormError(`Codex auth failed: ${e.data.message || "unknown error"}`); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [setFormError, submitCodexAuthInput]); useEffect( () => () => { if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; } }, [], ); const startCodexAuth = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); return; } if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); } codexPopupPollRef.current = setInterval(() => { if (popup.closed) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; setCodexAuthWaiting(false); } }, 500); }; const completeCodexAuth = async () => { await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { const result = await disconnectCodex(); if (!result.ok) { setFormError(result.error || "Failed to disconnect Codex"); return; } setCodexAuthStarted(false); setCodexAuthWaiting(false); setCodexManualInput(""); await refreshCodexStatus(); }; return { codexStatus, codexLoading, codexManualInput, setCodexManualInput, codexExchanging, codexAuthStarted, codexAuthWaiting, startCodexAuth, completeCodexAuth, handleCodexDisconnect, }; }; ================================================ FILE: lib/public/js/components/onboarding/use-welcome-pairing.js ================================================ import { useEffect, useState } from "preact/hooks"; import { approvePairing, fetchPairings, fetchStatus, rejectPairing } from "../../lib/api.js"; import { usePolling } from "../../hooks/usePolling.js"; import { isChannelPaired } from "./pairing-utils.js"; export const useWelcomePairing = ({ isPairingStep = false, selectedPairingChannel = "", } = {}) => { const [pairingError, setPairingError] = useState(null); const [pairingComplete, setPairingComplete] = useState(false); const pairingStatusPoll = usePolling(fetchStatus, 3000, { enabled: isPairingStep, }); const pairingRequestsPoll = usePolling( async () => { const payload = await fetchPairings(); const allPending = payload.pending || []; return allPending.filter((p) => p.channel === selectedPairingChannel); }, 1000, { enabled: isPairingStep && !!selectedPairingChannel, dedupeInFlight: true, }, ); const pairingChannels = pairingStatusPoll.data?.channels || {}; const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel); useEffect(() => { if (isPairingStep && canFinishPairing) { setPairingComplete(true); } }, [isPairingStep, canFinishPairing]); const handlePairingApprove = async (id, channel, accountId = "") => { try { setPairingError(null); const result = await approvePairing(id, channel, accountId); if (!result.ok) throw new Error(result.error || "Could not approve pairing"); setPairingComplete(true); pairingRequestsPoll.refresh(); pairingStatusPoll.refresh(); } catch (err) { setPairingError(err.message || "Could not approve pairing"); } }; const handlePairingReject = async (id, channel, accountId = "") => { try { setPairingError(null); const result = await rejectPairing(id, channel, accountId); if (!result.ok) throw new Error(result.error || "Could not reject pairing"); pairingRequestsPoll.refresh(); } catch (err) { setPairingError(err.message || "Could not reject pairing"); } }; const resetPairingState = () => { setPairingError(null); setPairingComplete(false); }; return { pairingStatusPoll, pairingRequestsPoll, pairingChannels, canFinishPairing, pairingError, pairingComplete, handlePairingApprove, handlePairingReject, resetPairingState, }; }; ================================================ FILE: lib/public/js/components/onboarding/use-welcome-storage.js ================================================ import { useEffect, useState } from "preact/hooks"; import { kOnboardingStorageKey } from "../../lib/storage-keys.js"; export { kOnboardingStorageKey }; export const kOnboardingStepKey = "_step"; export const kPairingChannelKey = "_pairingChannel"; export const kOnboardingSetupErrorKey = "_lastSetupError"; const loadInitialSetupState = () => { try { return JSON.parse(localStorage.getItem(kOnboardingStorageKey) || "{}"); } catch { return {}; } }; export const useWelcomeStorage = ({ kSetupStepIndex, kPairingStepIndex, } = {}) => { const [initialSetupState] = useState(loadInitialSetupState); const [vals, setVals] = useState(() => ({ ...initialSetupState })); const [setupError, setSetupError] = useState(null); const initialSetupError = String( initialSetupState?.[kOnboardingSetupErrorKey] || "", ).trim(); const shouldRecoverFromSetupState = !!initialSetupError; const [step, setStep] = useState(() => { const parsedStep = Number.parseInt( String(initialSetupState?.[kOnboardingStepKey] || ""), 10, ); if (!Number.isFinite(parsedStep)) return -1; const clampedStep = Math.max(-1, Math.min(kPairingStepIndex, parsedStep)); if (clampedStep === kSetupStepIndex && shouldRecoverFromSetupState) return 0; return clampedStep; }); useEffect(() => { localStorage.setItem( kOnboardingStorageKey, JSON.stringify({ ...vals, [kOnboardingStepKey]: step, ...(setupError ? { [kOnboardingSetupErrorKey]: setupError } : {}), }), ); }, [vals, step, setupError]); const setValue = (key, value) => setVals((prev) => ({ ...prev, [key]: value })); return { vals, setVals, setValue, step, setStep, setupError, setSetupError, }; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-config.js ================================================ import { h } from "preact"; import htm from "htm"; import { kAllAiAuthFields } from "../../lib/model-config.js"; const html = htm.bind(h); export const kRepoModeNew = "new"; export const kRepoModeExisting = "existing"; export const kGithubFlowFresh = "fresh"; export const kGithubFlowImport = "import"; export const kGithubTargetRepoModeCreate = "create"; export const kGithubTargetRepoModeExistingEmpty = "existing-empty"; const hasValue = (value) => !!String(value || "").trim(); export const normalizeGithubRepoInput = (repoInput) => String(repoInput || "") .trim() .replace(/^git@github\.com:/, "") .replace(/^https:\/\/github\.com\//, "") .replace(/\.git$/, ""); export const isValidGithubRepoInput = (repoInput) => { const cleaned = normalizeGithubRepoInput(repoInput); if (!cleaned) return false; const parts = cleaned.split("/").filter(Boolean); return parts.length === 2 && !parts.some((part) => /\s/.test(part)); }; const getGithubGroupError = (vals) => { const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; if (!hasValue(vals.GITHUB_TOKEN)) { return "Enter a GitHub personal access token to continue."; } if (!hasValue(vals.GITHUB_WORKSPACE_REPO)) { return 'Enter the target repo as "owner/repo".'; } if (!isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) { return 'Target repo must be in "owner/repo" format.'; } if (githubFlow === kGithubFlowImport) { if (!hasValue(vals._GITHUB_SOURCE_REPO)) { return 'Enter the source repo as "owner/repo".'; } if (!isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO)) { return 'Source repo must be in "owner/repo" format.'; } } return ""; }; const getAiGroupError = (vals, ctx = {}) => { if (!hasValue(vals.MODEL_KEY) || !String(vals.MODEL_KEY).includes("/")) { return "Choose a model to continue."; } if (ctx.selectedProvider === "openai-codex" && ctx.codexLoading) { return "Checking Codex OAuth status. Try Next again in a moment."; } if (!ctx.hasAi) { return ctx.selectedProvider === "openai-codex" ? "Connect Codex OAuth to continue." : "Add credentials for the selected model provider to continue."; } return ""; }; const getChannelsGroupError = (vals) => { const hasTelegram = hasValue(vals.TELEGRAM_BOT_TOKEN); const hasDiscord = hasValue(vals.DISCORD_BOT_TOKEN); const hasSlackBot = hasValue(vals.SLACK_BOT_TOKEN); const hasSlackApp = hasValue(vals.SLACK_APP_TOKEN); if (hasSlackBot && !hasSlackApp) { return "Add the Slack app token to continue with Slack."; } if (!hasSlackBot && hasSlackApp) { return "Add the Slack bot token to continue with Slack."; } if (!hasTelegram && !hasDiscord && !(hasSlackBot && hasSlackApp)) { return "Add at least one channel to continue."; } return ""; }; export const getWelcomeGroupError = (groupId, vals, ctx = {}) => { switch (groupId) { case "github": return getGithubGroupError(vals); case "ai": return getAiGroupError(vals, ctx); case "channels": return getChannelsGroupError(vals); default: return ""; } }; export const kWelcomeGroups = [ { id: "github", title: "GitHub", description: "Auto-backup your config and workspace", fields: [ { key: "_GITHUB_SOURCE_REPO", label: "Source Repo", placeholder: "username/existing-openclaw", isText: true, }, { key: "GITHUB_WORKSPACE_REPO", label: "New Workspace Repo", placeholder: "username/my-agent", isText: true, }, { key: "GITHUB_TOKEN", label: "Personal Access Token", hint: html`Create a${" "}classic PAT${" "}with${" "}repo${" "}scope, or a${" "}fine-grained token${" "}with Contents + Metadata access`, placeholder: "ghp_... or github_pat_...", }, ], validate: (vals, ctx = {}) => !getWelcomeGroupError("github", vals, ctx), }, { id: "ai", title: "Primary Agent Model", description: "Choose your main model and authenticate its provider", fields: kAllAiAuthFields, validate: (vals, ctx = {}) => !getWelcomeGroupError("ai", vals, ctx), }, { id: "channels", title: "Channels", description: "At least one is required to talk to your agent", fields: [ { key: "TELEGRAM_BOT_TOKEN", label: "Telegram Bot Token", hint: html`From${" "}@BotFather${" "}·${" "}full guide`, placeholder: "123456789:AAH...", }, { key: "DISCORD_BOT_TOKEN", label: "Discord Bot Token", hint: html`From${" "}Developer Portal${" "}·${" "}full guide`, placeholder: "MTQ3...", }, { key: "SLACK_BOT_TOKEN", label: "Slack Bot Token", hint: html`From your Slack app's${" "}OAuth & Permissions${" "}page${" "}·${" "}full guide`, placeholder: "xoxb-...", }, { key: "SLACK_APP_TOKEN", label: "Slack App Token (Socket Mode)", hint: html`From${" "}Basic Information${" "}→ App-Level Tokens (needs${" "}connections:write${" "}scope)`, placeholder: "xapp-...", }, ], validate: (vals, ctx = {}) => !getWelcomeGroupError("channels", vals, ctx), }, { id: "tools", title: "Tools (optional)", description: "Enable extra capabilities for your agent", fields: [ { key: "BRAVE_API_KEY", label: "Brave Search API Key", hint: html`From${" "}brave.com/search/api${" "}-${" "}free tier available`, placeholder: "BSA...", }, ], validate: () => true, }, ]; export const findFirstInvalidWelcomeGroup = (vals, ctx = {}) => kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null; ================================================ FILE: lib/public/js/components/onboarding/welcome-form-step.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { SecretInput } from "../secret-input.js"; import { ActionButton } from "../action-button.js"; import { Badge } from "../badge.js"; import { SegmentedControl } from "../segmented-control.js"; import { getChannelMeta } from "../channels.js"; import { kGithubFlowFresh, kGithubFlowImport, kGithubTargetRepoModeCreate, kGithubTargetRepoModeExistingEmpty, } from "./welcome-config.js"; const html = htm.bind(h); const kChannelAccordionDefs = [ { id: "telegram", title: "Telegram", fieldKeys: ["TELEGRAM_BOT_TOKEN"] }, { id: "discord", title: "Discord", fieldKeys: ["DISCORD_BOT_TOKEN"] }, { id: "slack", title: "Slack", fieldKeys: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], }, ]; export const WelcomeFormStep = ({ activeGroup, vals, hasAi, setValue, modelOptions, modelsLoading, modelsError, canToggleFullCatalog, showAllModels, setShowAllModels, selectedProvider, codexLoading, codexStatus, startCodexAuth, handleCodexDisconnect, codexAuthStarted, codexAuthWaiting, codexManualInput, setCodexManualInput, completeCodexAuth, codexExchanging, visibleAiFieldKeys, error, step, totalGroups, goBack, goNext, loading, githubStepLoading, handleSubmit, }) => { const [showOptionalOpenai, setShowOptionalOpenai] = useState(false); const [showOptionalGemini, setShowOptionalGemini] = useState(false); const [expandedChannels, setExpandedChannels] = useState(() => new Set(["telegram"])); const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; const freshRepoMode = githubFlow === kGithubFlowImport ? kGithubTargetRepoModeCreate : vals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate; const githubTokenPlaceholder = githubFlow === kGithubFlowImport || freshRepoMode === kGithubTargetRepoModeExistingEmpty ? "ghp_... or github_pat_..." : "ghp_..."; useEffect(() => { if (activeGroup.id !== "github") return; }, [activeGroup.id]); useEffect(() => { if (step === totalGroups - 1) { setShowOptionalOpenai(!vals.OPENAI_API_KEY); setShowOptionalGemini(!vals.GEMINI_API_KEY); } }, [step === totalGroups - 1]); useEffect(() => { if (activeGroup.id !== "channels") return; setExpandedChannels((current) => { if (current.size > 0) return current; return new Set(["telegram"]); }); }, [activeGroup.id]); const renderStandardField = (field) => html`
<${SecretInput} key=${field.key} value=${vals[field.key] || ""} onInput=${(e) => setValue(field.key, e.target.value)} placeholder=${activeGroup.id === "github" && field.key === "GITHUB_TOKEN" ? githubTokenPlaceholder : field.placeholder || ""} isSecret=${!field.isText} inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" />

${activeGroup.id === "github" && field.key === "GITHUB_WORKSPACE_REPO" ? githubFlow === kGithubFlowImport ? "Your new project will live here" : freshRepoMode === kGithubTargetRepoModeExistingEmpty ? "Enter the owner/repo of an existing empty repository" : "A new private repo will be created for you" : activeGroup.id === "github" && field.key === "_GITHUB_SOURCE_REPO" ? "The repo to import from" : activeGroup.id === "github" && field.key === "GITHUB_TOKEN" ? githubFlow === kGithubFlowImport ? freshRepoMode === kGithubTargetRepoModeCreate ? html`Use a classic PAT with${" "}repo${" "}scope to create the target repo. Fine-grained works if the target already exists and can access both repos.` : html`Use a classic PAT with${" "}repo${" "}scope, or a fine-grained token with Contents + Metadata access to both the source repo and target repo` : freshRepoMode === kGithubTargetRepoModeExistingEmpty ? html`Use a classic PAT with${" "}repo${" "}scope, or a fine-grained token with Contents + Metadata access to this repo` : html`Use a classic PAT with${" "}repo${" "}scope to create a new private repository` : field.hint}

`; const fieldLookup = new Map((activeGroup.fields || []).map((field) => [field.key, field])); const toggleChannelSection = (channelId) => setExpandedChannels((current) => { const next = new Set(current); if (next.has(channelId)) { next.delete(channelId); } else { next.add(channelId); } return next; }); const renderChannelAccordion = () => html`
${kChannelAccordionDefs.map((section) => { const isExpanded = expandedChannels.has(section.id); const sectionFields = section.fieldKeys .map((fieldKey) => fieldLookup.get(fieldKey)) .filter(Boolean); const channelMeta = getChannelMeta(section.id); const hasValue = section.fieldKeys.some((fieldKey) => String(vals[fieldKey] || "").trim(), ); return html`
${isExpanded ? html`
${sectionFields.map((field) => renderStandardField(field))}
` : null}
`; })}
`; return html`

${activeGroup.title}

${activeGroup.description}

${activeGroup.validate(vals, { hasAi }) ? html`` : activeGroup.id !== "tools" ? html`Required` : null}
${activeGroup.id === "ai" && html`

${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}

${canToggleFullCatalog && html` `}
`} ${activeGroup.id === "ai" && selectedProvider === "openai-codex" && html`
Codex OAuth ${codexLoading ? html`Checking...` : codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
<${ActionButton} onClick=${startCodexAuth} tone=${codexStatus.connected || codexAuthStarted ? "neutral" : "primary"} size="sm" idleLabel=${codexStatus.connected ? "Reconnect Codex" : "Connect Codex OAuth"} className="font-medium" /> ${codexStatus.connected && html` <${ActionButton} onClick=${handleCodexDisconnect} tone="ghost" size="sm" idleLabel="Disconnect" className="font-medium" /> `}
${codexAuthStarted && html`

${codexAuthWaiting ? "Complete login in the popup. AlphaClaw should finish automatically, but if it doesn't, paste the full redirect URL from the address bar (starts with " : "Paste the full redirect URL from the address bar (starts with "} http://localhost:1455/auth/callback) ${codexAuthWaiting ? " to finish setup." : " to finish setup."}

setCodexManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> <${ActionButton} onClick=${completeCodexAuth} disabled=${!codexManualInput.trim() || codexExchanging} loading=${codexExchanging} tone="primary" size="sm" idleLabel="Complete Codex OAuth" loadingLabel="Completing..." className="font-medium" />
`}
`} ${activeGroup.id === "github" && html`
${githubFlow === kGithubFlowFresh ? html`
<${SegmentedControl} options=${[ { label: "Create new repo", value: kGithubTargetRepoModeCreate, }, { label: "Use existing empty repo", value: kGithubTargetRepoModeExistingEmpty, }, ]} value=${freshRepoMode} onChange=${(value) => setValue("_GITHUB_TARGET_REPO_MODE", value)} fullWidth=${true} />
` : null}
`} ${activeGroup.id === "channels" ? renderChannelAccordion() : (activeGroup.id === "ai" ? activeGroup.fields.filter((field) => visibleAiFieldKeys.has(field.key), ) : activeGroup.id === "github" ? activeGroup.fields.filter((field) => githubFlow === kGithubFlowImport ? true : field.key !== "_GITHUB_SOURCE_REPO", ) : activeGroup.fields ).map((field) => renderStandardField(field))} ${error ? html`
${error}
` : null} ${step === totalGroups - 1 && (showOptionalOpenai || showOptionalGemini) ? html` ${showOptionalOpenai ? html`
<${SecretInput} value=${vals.OPENAI_API_KEY || ""} onInput=${(e) => setValue("OPENAI_API_KEY", e.target.value)} placeholder="sk-..." isSecret=${true} inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" />

Used for memory embeddings -${" "} get key

` : null} ${showOptionalGemini ? html`
<${SecretInput} value=${vals.GEMINI_API_KEY || ""} onInput=${(e) => setValue("GEMINI_API_KEY", e.target.value)} placeholder="AI..." isSecret=${true} inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" />

Used for memory embeddings and Nano Banana -${" "} get key

` : null} ` : null}
${step < totalGroups - 1 ? html` ${step >= 0 ? html`<${ActionButton} onClick=${goBack} tone="secondary" size="md" idleLabel="Back" className="w-full" />` : html`
`} <${ActionButton} onClick=${goNext} loading=${activeGroup.id === "github" && githubStepLoading} tone="primary" size="md" idleLabel=${activeGroup.id === "github" && githubFlow === kGithubFlowImport ? "Check compatibility" : "Next"} loadingLabel="Checking..." className="w-full" /> ` : html` ${step >= 0 ? html`<${ActionButton} onClick=${goBack} tone="secondary" size="md" idleLabel="Back" className="w-full" />` : html`
`} <${ActionButton} onClick=${handleSubmit} loading=${loading} tone="primary" size="md" idleLabel="Next" loadingLabel="Starting..." className="w-full" /> `}
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-header.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const WelcomeHeader = ({ groups, step, isPreStep, isSetupStep, isPairingStep, stepNumber, activeStepLabel, }) => { const progressSteps = [ ...groups, { id: "setup", title: "Initializing" }, { id: "pairing", title: "Pairing" }, ]; return html`

Setup

Let's get your agent running

${isPreStep ? "Choose your destiny" : `Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel}`}
${progressSteps.map((group, idx) => { const isActive = idx === step; const isComplete = idx < step || (isSetupStep && group.id === "setup"); const isPairingComplete = idx < step || (isPairingStep && group.id === "pairing"); const bg = isPreStep ? "var(--border-strong)" : isActive ? "var(--accent)" : group.id === "pairing" ? isPairingComplete ? "var(--accent-dim)" : "var(--border-strong)" : isComplete ? "var(--accent-dim)" : "var(--border-strong)"; return html`
`; })}
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-import-step.js ================================================ import { h } from "preact"; import { useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { LoadingSpinner } from "../loading-spinner.js"; import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js"; const html = htm.bind(h); const kCategories = [ { key: "gatewayConfig", label: "Gateway Config", icon: "⚙️", description: "openclaw.json configuration", showFiles: true, }, { key: "envFiles", label: "Environment Files", icon: "🔐", description: ".env files with variables", showFiles: true, }, { key: "workspaceFiles", label: "Workspace Files", icon: "📄", description: "Prompt files (AGENTS.md, SOUL.md, etc.)", showFiles: true, }, { key: "skills", label: "Skills", icon: "🛠", description: "Custom skill definitions", showFiles: true, }, { key: "cronJobs", label: "Cron Jobs", icon: "⏰", description: "Scheduled tasks", showFiles: true, }, { key: "webhooks", label: "Hooks", icon: "🔗", description: "Webhook mappings and internal hooks", showDirs: true, }, { key: "memory", label: "Memory", icon: "🧠", description: "Agent memory and embeddings", showDirs: true, }, ]; const CategoryCard = ({ category, data }) => { const [expanded, setExpanded] = useState(false); if (!data?.found) return null; const isHooksCategory = category.key === "webhooks"; const warningItems = Array.isArray(data.transformWarnings) ? data.transformWarnings : []; const warningPathPrefixes = new Set( warningItems .map((warning) => String(warning.actualPath || "").trim()) .filter(Boolean) .map((pathValue) => pathValue.split("/").slice(0, -2).join("/")), ); const items = [ ...(data.jobNames || []), ...(data.hookNames || []), ...(data.files || []), ...(data.dirs || []).filter((dir) => !warningPathPrefixes.has(dir)), ...(data.extraMarkdown || []), ]; const count = typeof data.jobCount === "number" && data.jobCount > 0 ? data.jobCount : typeof data.hookCount === "number" && data.hookCount > 0 ? data.hookCount : items.length; const warningCount = typeof data.warningCount === "number" ? data.warningCount : warningItems.length; return html`
${expanded && items.length > 0 && html`
${items.map( (item) => html`
${item}
`, )} ${isHooksCategory ? warningItems.map( (warning) => html`
${warning.actualPath}
`, ) : null}
`}
`; }; export const WelcomeImportStep = ({ scanResult, scanning, error, onApprove, onShowSecretReview, onBack, }) => { if (scanning) { return html`
<${LoadingSpinner} />

Scanning repository...

`; } if (error) { return html`
${error}
`; } if (!scanResult) return null; const secretCount = (scanResult.secrets || []).length; const hasConflicts = scanResult.managedConflicts?.found; return html`

Import Summary

${scanResult.hasOpenclawSetup ? "Found an existing OpenClaw setup" : "No OpenClaw config detected — we'll set up fresh after import"}

${kCategories.map( (cat) => html` <${CategoryCard} key=${cat.key} category=${cat} data=${scanResult[cat.key]} /> `, )}
${scanResult.credentials?.found && html`
Deployment-specific files found (credentials, device identity) — these will not be imported.
`} ${hasConflicts && html`
AlphaClaw-managed files detected (${(scanResult.managedConflicts.files || []).join(", ")}). These will be overwritten with AlphaClaw defaults.
`} ${scanResult.managedEnvConflicts?.found ? html`
AlphaClaw controls deployment tokens and env vars (${(scanResult.managedEnvConflicts.vars || []).join(", ")}). Imported values for these will be overwritten with AlphaClaw-managed env var references during import.
` : null} ${scanResult.webhooks?.warningCount > 0 ? html`
AlphaClaw expects hook transforms at hooks/transforms/name/name-transform.mjs. We found some that do not match and will try to patch them during import. The originals will be backed up under hooks/transforms/_backup.
` : null} ${secretCount > 0 && html`
${`${secretCount} possible secret${secretCount === 1 ? "" : "s"} detected`}

Review and extract to environment variables

<${ActionButton} onClick=${onShowSecretReview} tone="primary" size="sm" idleLabel="Review" className="font-medium" />
`}
<${ActionButton} onClick=${onBack} tone="secondary" size="md" idleLabel="Back" className="w-full" /> <${ActionButton} onClick=${() => onApprove(buildApprovedImportSecrets(scanResult.secrets))} loading=${scanning} tone="primary" size="md" idleLabel="Import" loadingLabel="Importing..." className="w-full" />
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-pairing-step.js ================================================ import { h } from "preact"; import { useState } from "preact/hooks"; import htm from "htm"; import { Badge } from "../badge.js"; const html = htm.bind(h); const kChannelMeta = { telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg", }, discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg", }, slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg", }, whatsapp: { label: "WhatsApp", iconSrc: "/assets/icons/whatsapp.svg", }, }; const PairingRow = ({ pairing, onApprove, onReject }) => { const [busyAction, setBusyAction] = useState(""); const handleApprove = async () => { setBusyAction("approve"); try { await onApprove(pairing.id, pairing.channel, pairing.accountId || ""); } finally { setBusyAction(""); } }; const handleReject = async () => { setBusyAction("reject"); try { await onReject(pairing.id, pairing.channel, pairing.accountId || ""); } finally { setBusyAction(""); } }; return html`
${pairing.code || pairing.id || "Pending request"}
Request

Approve to connect this account and finish setup.

`; }; export const WelcomePairingStep = ({ channel, pairings, loading, error, onApprove, onReject, canFinish, onContinue, onSkip, }) => { const channelMeta = kChannelMeta[channel] || { label: channel ? channel.charAt(0).toUpperCase() + channel.slice(1) : "Channel", iconSrc: "", }; if (!channel) { return html`
Missing channel configuration. Go back and add a channel credential.
`; } if (canFinish) { return html`

🎉 Setup complete

Your ${channelMeta.label} channel is connected. You can switch to${" "} ${channelMeta.label} and start using your agent now.

Continue to the dashboard to explore extras like Google Workspace and additional integrations.

`; } return html`
<${Badge} tone="warning" >${ loading ? "Checking..." : pairings.length > 0 ? "Pairing request detected" : "Awaiting pairing" }
${ pairings.length > 0 ? html`
${pairings.map( (pairing) => html`<${PairingRow} key=${pairing.id} pairing=${pairing} onApprove=${onApprove} onReject=${onReject} />`, )}
` : html`
${channelMeta.iconSrc ? html`${channelMeta.label}` : null}

Send a message to your ${channelMeta.label} bot

The pairing request will appear here in 5-10 seconds

` } ${ error ? html`
${error}
` : null } ${ pairings.length === 0 ? html`
` : null }
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-placeholder-review-step.js ================================================ import { h } from "preact"; import { useMemo } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { SecretInput } from "../secret-input.js"; const html = htm.bind(h); const isResolvedValue = (value) => { const normalized = String(value || "").trim(); return !!normalized && normalized !== "placeholder"; }; const PlaceholderRow = ({ item, value, onInput }) => { return html`
${item.key}
<${SecretInput} value=${value} onInput=${(event) => onInput(event.target.value)} placeholder="Enter value" inputClass="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted font-mono" />
`; }; export const WelcomePlaceholderReviewStep = ({ placeholderReview, vals, setValue, onContinue, }) => { const items = Array.isArray(placeholderReview?.vars) ? placeholderReview.vars : []; const unresolvedItems = useMemo( () => items .filter((item) => !isResolvedValue(vals[item.key])) .map((item) => item.key), [items, vals], ); const unresolvedCount = unresolvedItems.length; if (items.length === 0) return null; return html`

Add Missing Env Vars

${items.map( (item) => html` <${PlaceholderRow} key=${item.key} item=${item} value=${String(vals[item.key] || "") === "placeholder" ? "" : vals[item.key] || ""} onInput=${(nextValue) => setValue(item.key, nextValue)} /> `, )}
${unresolvedCount > 0 ? `${unresolvedCount} detected env var${unresolvedCount === 1 ? "" : "s"} need values. You can continue without them, but the gateway might fail to start.` : "All imported placeholder env vars have values now."}
<${ActionButton} onClick=${onContinue} tone="primary" size="md" idleLabel=${unresolvedCount > 0 ? `Continue with ${unresolvedCount} Unresolved` : "Continue"} className="w-full" />
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-pre-step.js ================================================ import { h } from "preact"; import htm from "htm"; import { kGithubFlowFresh, kGithubFlowImport } from "./welcome-config.js"; const html = htm.bind(h); export const WelcomePreStep = ({ onSelectFlow }) => { return html`
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-secret-review-step.js ================================================ import { h } from "preact"; import { useState, useCallback } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; import { LoadingSpinner } from "../loading-spinner.js"; import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js"; const html = htm.bind(h); const SecretRow = ({ secret, selected, onToggle, envVarName, onEnvVarChange }) => html`
${secret.maskedValue} ${secret.confidence === "high" ? html`high confidence` : html`possible`}
Found in${" "} ${secret.file || "config"} ${secret.configPath ? html` at ${secret.configPath}` : null}
${secret.duplicateIn && html`
Also found in${" "}${secret.duplicateIn}
`}
${selected && html`
onEnvVarChange(e.target.value)} class="w-full mt-1 bg-field border border-border rounded-lg px-3 py-1.5 text-xs text-body outline-none focus:border-fg-muted font-mono" />
`}
`; export const WelcomeSecretReviewStep = ({ secrets = [], onApprove, onBack, loading, error, }) => { const [selections, setSelections] = useState(() => { const initial = {}; for (const secret of secrets) { initial[secret.configPath] = { selected: secret.confidence === "high", envVarName: secret.suggestedEnvVar || "", }; } return initial; }); const toggleSecret = useCallback( (configPath) => { setSelections((prev) => ({ ...prev, [configPath]: { ...prev[configPath], selected: !prev[configPath]?.selected, }, })); }, [], ); const updateEnvVarName = useCallback( (configPath, name) => { setSelections((prev) => ({ ...prev, [configPath]: { ...prev[configPath], envVarName: name, }, })); }, [], ); const selectedCount = Object.values(selections).filter( (s) => s.selected, ).length; const handleExtract = () => { const approved = buildApprovedImportSecrets( secrets.map((secret) => ({ ...secret, confidence: selections[secret.configPath]?.selected ? "high" : "medium", suggestedEnvVar: selections[secret.configPath]?.envVarName || secret.suggestedEnvVar, })), ); onApprove(approved); }; if (loading) { return html`
<${LoadingSpinner} />

Applying import...

`; } return html`

Review Secrets

Select secrets to extract into environment variables. Inline values in config will be replaced with ${"`"}${"${"}ENV_VAR_NAME${"}"}${"`"} references.

${error && html`
${error}
`}
${secrets.map( (secret) => html` <${SecretRow} key=${secret.configPath} secret=${secret} selected=${selections[secret.configPath]?.selected || false} envVarName=${selections[secret.configPath]?.envVarName || ""} onToggle=${() => toggleSecret(secret.configPath)} onEnvVarChange=${(name) => updateEnvVarName(secret.configPath, name)} /> `, )}
<${ActionButton} onClick=${onBack} tone="secondary" size="md" idleLabel="Back" className="w-full" /> <${ActionButton} onClick=${handleExtract} tone="primary" size="md" idleLabel=${selectedCount > 0 ? `Extract ${selectedCount} Secret${selectedCount === 1 ? "" : "s"}` : "Skip All"} className="w-full" />
`; }; ================================================ FILE: lib/public/js/components/onboarding/welcome-secret-review-utils.js ================================================ export const buildApprovedImportSecrets = (secrets = []) => (Array.isArray(secrets) ? secrets : []) .filter((secret) => secret?.confidence === "high") .map((secret) => ({ ...secret, suggestedEnvVar: secret?.suggestedEnvVar || "", })); export const buildApprovedImportVals = (approvedSecrets = []) => (Array.isArray(approvedSecrets) ? approvedSecrets : []).reduce( (nextVals, secret) => { const envVar = String(secret?.suggestedEnvVar || "").trim(); const value = String(secret?.value || ""); if (!envVar || !value) return nextVals; nextVals[envVar] = value; return nextVals; }, {}, ); ================================================ FILE: lib/public/js/components/onboarding/welcome-setup-step.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { LoadingSpinner } from "../loading-spinner.js"; const html = htm.bind(h); const kSetupTips = [ { label: "🛡️ Safety tip", text: "Be careful what you give access to. Read access is always safer than write access.", }, { label: "🧠 Best practice", text: "Trust but verify. Your agent may not always know what it's doing, so check the results.", }, { label: "💡 Idea", text: "Ask your agent to create a morning briefing for you.", }, { label: "🧠 Best practice", text: "Ask your agent to review its own code and make sure it's doing what you want it to do.", }, { label: "💡 Idea", text: "Tell your agent to review the latest news and provide a summary.", }, { label: "🛡️ Safety tip", text: "Be incredibly careful installing skills from the internet - they may contain malicious code.", }, ]; export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => { const [tipIndex, setTipIndex] = useState(0); useEffect(() => { if (error || !loading) return; const timer = setInterval(() => { setTipIndex((idx) => (idx + 1) % kSetupTips.length); }, 5200); return () => clearInterval(timer); }, [error, loading]); if (error) { return html`

Setup failed

Fix the values and try again.

${error}
`; } const currentTip = kSetupTips[tipIndex]; return html`
<${LoadingSpinner} className="h-8 w-8 text-body" />

Initializing OpenClaw...

This could take 10-15 seconds

${currentTip.label}: ${currentTip.text}
`; }; ================================================ FILE: lib/public/js/components/overflow-menu.js ================================================ import { h } from "preact"; import { useEffect, useRef } from "preact/hooks"; import htm from "htm"; const html = htm.bind(h); const VerticalDotsIcon = ({ className = "" }) => html` `; export const OverflowMenu = ({ open = false, onToggle = () => {}, onClose = () => {}, ariaLabel = "Open menu", title = "", menuRef = null, renderTrigger = null, triggerDisabled = false, children = null, }) => { const internalMenuRef = useRef(null); const setMenuNodeRef = (node) => { internalMenuRef.current = node; if (typeof menuRef === "function") { menuRef(node); return; } if (menuRef && typeof menuRef === "object") { menuRef.current = node; } }; useEffect(() => { if (!open) return undefined; const handleWindowClick = (event) => { const root = internalMenuRef.current; if (!root) return; if (root.contains(event.target)) return; onClose(event); }; window.addEventListener("click", handleWindowClick); return () => window.removeEventListener("click", handleWindowClick); }, [open, onClose]); return html`
${typeof renderTrigger === "function" ? renderTrigger({ open, onToggle: (event) => { event.stopPropagation(); onToggle(event); }, ariaLabel, title: title || ariaLabel, }) : html` `} ${open ? html`
event.stopPropagation()}> ${children}
` : null}
`; }; export const OverflowMenuItem = ({ children = null, onClick = () => {}, className = "", iconSrc = "", disabled = false, }) => html` `; ================================================ FILE: lib/public/js/components/page-header.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const PageHeader = ({ title = "", actions = null, leading = null }) => html`
${leading || html`

${title}

`}
${actions}
`; ================================================ FILE: lib/public/js/components/pairings.js ================================================ import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; import htm from 'htm'; import { ActionButton } from './action-button.js'; import { LoadingSpinner } from './loading-spinner.js'; const html = htm.bind(h); export const PairingRow = ({ p, onApprove, onReject }) => { const [busy, setBusy] = useState(null); const handle = async (action) => { setBusy(action); try { if (action === "approve") await onApprove(p.id, p.channel, p.accountId); else await onReject(p.id, p.channel, p.accountId); } catch { setBusy(null); } }; const label = (p.channel || 'unknown').charAt(0).toUpperCase() + (p.channel || '').slice(1); const accountId = String(p.accountId || "").trim(); const accountName = String(p.accountName || "").trim(); const accountSuffix = accountId && accountId !== "default" ? ` · ${accountName || accountId}` : ""; if (busy === "approve") { return html`
Approved ${label}${accountSuffix} · ${p.code || p.id || '?'}
`; } if (busy === "reject") { return html`
Rejected ${label}${accountSuffix} · ${p.code || p.id || '?'}
`; } return html`
${label}${accountSuffix} · ${p.code || p.id || '?'}
<${ActionButton} onClick=${() => handle("approve")} tone="success" size="sm" idleLabel="Approve" className="font-medium px-3 py-1.5" /> <${ActionButton} onClick=${() => handle("reject")} tone="secondary" size="sm" idleLabel="Reject" className="font-medium px-3 py-1.5" />
`; }; const ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp']; const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); const getPairingKey = (p) => { const channel = String(p?.channel || "").trim().toLowerCase(); const accountId = String(p?.accountId || "").trim() || "default"; const id = String(p?.id || p?.code || "").trim(); return channel && id ? `${channel}\u0000${accountId}\u0000${id}` : ""; }; export function Pairings({ pending, channels, visible, onApprove, onReject, statusRefreshing = false, pollingInFlight = false, }) { const [hiddenPairingKeys, setHiddenPairingKeys] = useState(() => new Set()); const pendingList = Array.isArray(pending) ? pending : []; useEffect(() => { setHiddenPairingKeys((current) => { if (current.size === 0) return current; const pendingKeys = new Set( pendingList.map(getPairingKey).filter(Boolean), ); const next = new Set(); for (const key of current) { if (pendingKeys.has(key)) { next.add(key); } } return next.size === current.size ? current : next; }); }, [pending]); const hidePairing = (p) => { const key = getPairingKey(p); if (!key) return; setHiddenPairingKeys((current) => { if (current.has(key)) return current; const next = new Set(current); next.add(key); return next; }); }; const handleApprove = async (p) => { await onApprove(p.id, p.channel, p.accountId); hidePairing(p); }; const handleReject = async (p) => { await onReject(p.id, p.channel, p.accountId); hidePairing(p); }; const visiblePending = pendingList.filter( (p) => !hiddenPairingKeys.has(getPairingKey(p)), ); if (!visible) return null; const unpaired = ALL_CHANNELS .filter((ch) => { const info = channels?.[ch]; if (!info) return false; const accounts = info.accounts && typeof info.accounts === "object" ? info.accounts : {}; if (Object.keys(accounts).length > 0) { return Object.values(accounts).some( (acc) => acc && acc.status !== "paired", ); } return info.status !== "paired"; }) .map(capitalize); const channelList = unpaired.length <= 2 ? unpaired.join(' or ') : unpaired.slice(0, -1).join(', ') + ', or ' + unpaired[unpaired.length - 1]; if (unpaired.length === 0 && visiblePending.length === 0) return null; return html`

Pending Pairings

${pollingInFlight ? html`
<${LoadingSpinner} className="h-3.5 w-3.5 text-fg-muted" />
` : null}
${visiblePending.length > 0 ? html`
${visiblePending.map((p) => html` <${PairingRow} key=${getPairingKey(p) || p.id} p=${p} onApprove=${() => handleApprove(p)} onReject=${() => handleReject(p)} /> `)}
` : statusRefreshing ? html`

Updating pairing status...

` : html`
💬

Send a message to your bot on ${channelList}

The pairing request will appear here — it may take a few moments

`}
`; } ================================================ FILE: lib/public/js/components/pane-shell.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); /** * Shared layout shell for pages that need a fixed header with a * separately scrollable body. The header stays pinned at the top * while body content scrolls underneath. * * @param {preact.ComponentChildren} props.header Content rendered in the fixed header area. * @param {preact.ComponentChildren} props.children Content rendered in the scrollable body. */ export const PaneShell = ({ header, children }) => html`
${header}
${children}
`; ================================================ FILE: lib/public/js/components/pill-tabs.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); const kPillBaseClassName = "inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"; const kPillActiveClassName = "border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]"; const kPillInactiveClassName = "border-border bg-field text-fg-muted hover:border-fg-muted hover:text-body"; export const PillTabs = ({ tabs = [], activeTab = "", onSelectTab = () => {}, className = "flex items-center gap-2", } = {}) => html`
${tabs.map( (tab) => html` `, )}
`; ================================================ FILE: lib/public/js/components/pop-actions.js ================================================ import { h } from "preact"; import { useState, useEffect, useRef } from "preact/hooks"; import htm from "htm"; const html = htm.bind(h); const kEnterDurationMs = 260; const kExitDurationMs = 200; /** * Wrapper that pop-animates children in/out based on `visible`. * Use for header save/cancel actions or any contextual action group. * * @param {boolean} props.visible Whether the actions should be shown. * @param {string} [props.className] Extra classes on the container. * @param {preact.ComponentChildren} props.children */ export const PopActions = ({ visible = false, className = "", children }) => { const [phase, setPhase] = useState(visible ? "visible" : "hidden"); const enterTimerRef = useRef(null); const exitTimerRef = useRef(null); useEffect(() => { clearTimeout(enterTimerRef.current); clearTimeout(exitTimerRef.current); if (visible) { if (phase !== "visible") { setPhase("entering"); enterTimerRef.current = setTimeout( () => setPhase("visible"), kEnterDurationMs, ); } } else if (phase !== "hidden") { setPhase("exiting"); exitTimerRef.current = setTimeout(() => setPhase("hidden"), kExitDurationMs); } return () => { clearTimeout(enterTimerRef.current); clearTimeout(exitTimerRef.current); }; }, [visible, phase]); const phaseClass = phase === "entering" ? "ac-pop-actions-in" : phase === "exiting" ? "ac-pop-actions-out" : phase === "visible" ? "ac-pop-actions-visible" : "ac-pop-actions-hidden"; return html`
${children}
`; }; ================================================ FILE: lib/public/js/components/providers.js ================================================ import { h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import htm from "htm"; import { fetchEnvVars, saveEnvVars, fetchModels, fetchModelStatus, setPrimaryModel, fetchCodexStatus, disconnectCodex, exchangeCodexOAuth, } from "../lib/api.js"; import { showToast } from "./toast.js"; import { Badge } from "./badge.js"; import { SecretInput } from "./secret-input.js"; import { PageHeader } from "./page-header.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { ActionButton } from "./action-button.js"; import { getModelProvider, getAuthProviderFromModelProvider, getFeaturedModels, kProviderAuthFields, kProviderLabels, kProviderOrder, kProviderFeatures, kCoreProviders, } from "../lib/model-config.js"; import { isCodexAuthCallbackMessage, openCodexAuthWindow, } from "../lib/codex-oauth-window.js"; const html = htm.bind(h); const getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || ""; const kAiCredentialKeys = Object.values(kProviderAuthFields) .flat() .map((field) => field.key) .filter((key, idx, arr) => arr.indexOf(key) === idx); let kProvidersTabCache = null; const FeatureTags = ({ provider, features = null }) => { const resolvedFeatures = Array.isArray(features) ? features : kProviderFeatures[provider] || []; const uniqueFeatures = Array.from(new Set(resolvedFeatures)); if (!uniqueFeatures.length) return null; return html`
${uniqueFeatures.map( (f) => html` ${f} `, )}
`; }; export const Providers = ({ onRestartRequired = () => {} }) => { const [envVars, setEnvVars] = useState( () => kProvidersTabCache?.envVars || [], ); const [models, setModels] = useState(() => kProvidersTabCache?.models || []); const [selectedModel, setSelectedModel] = useState( () => kProvidersTabCache?.selectedModel || "", ); const [showAllModels, setShowAllModels] = useState( () => kProvidersTabCache?.showAllModels || false, ); const [savingChanges, setSavingChanges] = useState(false); const [codexStatus, setCodexStatus] = useState( () => kProvidersTabCache?.codexStatus || { connected: false }, ); const [codexManualInput, setCodexManualInput] = useState(""); const [codexExchanging, setCodexExchanging] = useState(false); const [codexAuthStarted, setCodexAuthStarted] = useState(false); const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); const [modelsLoading, setModelsLoading] = useState(() => !kProvidersTabCache); const [modelsError, setModelsError] = useState( () => kProvidersTabCache?.modelsError || "", ); const [ready, setReady] = useState(() => !!kProvidersTabCache); const [savedModel, setSavedModel] = useState( () => kProvidersTabCache?.savedModel || "", ); const [modelDirty, setModelDirty] = useState(false); const [savedAiValues, setSavedAiValues] = useState( () => kProvidersTabCache?.savedAiValues || {}, ); const [showMoreProviders, setShowMoreProviders] = useState(false); const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { if (!ready) setModelsLoading(true); setModelsError(""); try { const [env, modelCatalog, modelStatus, codex] = await Promise.all([ fetchEnvVars(), fetchModels(), fetchModelStatus(), fetchCodexStatus(), ]); setEnvVars(env.vars || []); const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : []; setModels(catalogModels); const currentModel = modelStatus.modelKey || ""; setSelectedModel(currentModel); setCodexStatus(codex || { connected: false }); setSavedModel(currentModel); setModelDirty(false); const nextSavedAiValues = Object.fromEntries( kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]), ); setSavedAiValues(nextSavedAiValues); const nextModelsError = catalogModels.length ? "" : "No models found"; setModelsError(nextModelsError); kProvidersTabCache = { envVars: env.vars || [], models: catalogModels, selectedModel: currentModel, savedModel: currentModel, savedAiValues: nextSavedAiValues, codexStatus: codex || { connected: false }, showAllModels, modelsError: nextModelsError, }; } catch (err) { setModelsError("Failed to load provider settings"); showToast(`Failed to load provider settings: ${err.message}`, "error"); } finally { setReady(true); setModelsLoading(false); } }; const refreshCodexConnection = async () => { try { const codex = await fetchCodexStatus(); setCodexStatus(codex || { connected: false }); if (codex?.connected) { setCodexAuthStarted(false); setCodexAuthWaiting(false); } kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: codex || { connected: false }, }; } catch { setCodexStatus({ connected: false }); kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: { connected: false }, }; } }; useEffect(() => { refresh(); }, []); useEffect( () => () => { if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; } }, [], ); const submitCodexAuthInput = async (input) => { const normalizedInput = String(input || "").trim(); if (!normalizedInput || codexExchangeInFlightRef.current) return; codexExchangeInFlightRef.current = true; setCodexManualInput(normalizedInput); setCodexExchanging(true); try { const result = await exchangeCodexOAuth(normalizedInput); if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); setCodexManualInput(""); showToast("Codex connected", "success"); setCodexAuthStarted(false); setCodexAuthWaiting(false); await refreshCodexConnection(); } catch (err) { setCodexAuthWaiting(false); showToast(err.message || "Codex OAuth exchange failed", "error"); } finally { codexExchangeInFlightRef.current = false; setCodexExchanging(false); } }; useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); } else if (isCodexAuthCallbackMessage(e.data)) { await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, "error", ); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { const existing = prev.some((entry) => entry.key === key); const next = existing ? prev.map((v) => (v.key === key ? { ...v, value } : v)) : [...prev, { key, value, editable: true }]; kProvidersTabCache = { ...(kProvidersTabCache || {}), envVars: next }; return next; }); }; const selectedModelProvider = getModelProvider(selectedModel); const selectedAuthProvider = getAuthProviderFromModelProvider( selectedModelProvider, ); const primaryProvider = kProviderOrder.includes(selectedAuthProvider) ? selectedAuthProvider : kProviderOrder[0]; const otherProviders = kProviderOrder.filter((p) => p !== primaryProvider); const featuredModels = getFeaturedModels(models); const baseModelOptions = showAllModels ? models : featuredModels.length > 0 ? featuredModels : models; const selectedModelOption = models.find( (model) => model.key === selectedModel, ); const modelOptions = selectedModelOption && !baseModelOptions.some((model) => model.key === selectedModelOption.key) ? [...baseModelOptions, selectedModelOption] : baseModelOptions; const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length; const aiCredentialsDirty = kAiCredentialKeys.some( (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = selectedModelProvider === "openai-codex" ? !!codexStatus.connected : (kProviderAuthFields[selectedAuthProvider] || []).some((field) => Boolean(getKeyVal(envVars, field.key)), ); const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth)); const saveChanges = async () => { if (savingChanges) return; if (!modelDirty && !aiCredentialsDirty) return; if (modelDirty && !hasSelectedProviderAuth) { showToast( "Add credentials for the selected model provider before saving model changes", "error", ); return; } setSavingChanges(true); try { const targetModel = selectedModel; if (aiCredentialsDirty) { const payload = envVars .filter((v) => v.editable) .map((v) => ({ key: v.key, value: v.value })); const envResult = await saveEnvVars(payload); if (!envResult.ok) throw new Error(envResult.error || "Failed to save env vars"); if (envResult.restartRequired) onRestartRequired(true); } if (modelDirty && targetModel) { const modelResult = await setPrimaryModel(targetModel); if (!modelResult.ok) throw new Error(modelResult.error || "Failed to set primary model"); const status = await fetchModelStatus(); if (status?.ok === false) { throw new Error(status.error || "Failed to verify primary model"); } const activeModel = status?.modelKey || ""; if (activeModel && activeModel !== targetModel) { throw new Error( `Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`, ); } setSavedModel(targetModel); setModelDirty(false); kProvidersTabCache = { ...(kProvidersTabCache || {}), selectedModel: targetModel, savedModel: targetModel, }; } await refresh(); showToast("Changes saved", "success"); } catch (err) { showToast(err.message || "Failed to save changes", "error"); } finally { setSavingChanges(false); } }; const startCodexAuth = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); return; } if (codexPopupPollRef.current) { clearInterval(codexPopupPollRef.current); } codexPopupPollRef.current = setInterval(() => { if (popup.closed) { clearInterval(codexPopupPollRef.current); codexPopupPollRef.current = null; setCodexAuthWaiting(false); } }, 500); }; const completeCodexAuth = async () => { await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { const result = await disconnectCodex(); if (!result.ok) { showToast(result.error || "Failed to disconnect Codex", "error"); return; } showToast("Codex disconnected", "success"); setCodexAuthStarted(false); setCodexAuthWaiting(false); setCodexManualInput(""); await refreshCodexConnection(); }; const renderCredentialField = (field) => html`
${field.url && !getKeyVal(envVars, field.key) ? html`Get` : null}
<${SecretInput} value=${getKeyVal(envVars, field.key)} onInput=${(e) => setEnvValue(field.key, e.target.value)} placeholder=${field.placeholder || ""} isSecret=${!field.isText} inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" /> ${field.hint ? html`

${field.hint}

` : null}
`; const renderCodexOAuth = () => html`
Codex OAuth ${codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
${codexAuthStarted ? html`

${codexAuthWaiting ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." : "Paste the redirect URL from your browser to finish connecting."}

` : codexStatus.connected ? html`
` : html` `} ${codexAuthStarted ? html`

After login, copy the full redirect URL (starts with http://localhost:1455/auth/callback) and paste it here.

setCodexManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> <${ActionButton} onClick=${completeCodexAuth} disabled=${!codexManualInput.trim() || codexExchanging} loading=${codexExchanging} tone="primary" size="sm" idleLabel="Complete Codex OAuth" loadingLabel="Completing..." className="text-xs font-medium px-3 py-1.5" /> ` : null}
`; const providerHasKey = (provider) => { const fields = kProviderAuthFields[provider] || []; return fields.some((f) => !!getKeyVal(envVars, f.key)); }; const renderProviderCard = (provider) => { const fields = kProviderAuthFields[provider] || []; const hasCodex = provider === "openai"; const hasKey = providerHasKey(provider); const openAiFeatures = kProviderFeatures.openai || []; return html`

${kProviderLabels[provider] || provider}

${hasKey ? html`` : null}
${fields.map((field) => renderCredentialField(field))} ${provider === "openai" ? html`<${FeatureTags} features=${openAiFeatures} />` : null} ${hasCodex ? renderCodexOAuth() : null} ${provider !== "openai" ? html`<${FeatureTags} provider=${provider} />` : null}
`; }; if (!ready) { return html`
<${PageHeader} title="Providers" actions=${html` <${ActionButton} disabled=${true} tone="primary" size="sm" idleLabel="Save changes" className="transition-all" /> `} />
<${LoadingSpinner} className="h-4 w-4" /> Loading provider settings...
`; } const renderPrimaryProviderContent = () => { const fields = kProviderAuthFields[primaryProvider] || []; const hasCodex = primaryProvider === "openai"; return html` ${fields.map((field) => renderCredentialField(field))} ${hasCodex ? renderCodexOAuth() : null} `; }; return html`
<${PageHeader} title="Providers" actions=${html` <${ActionButton} onClick=${saveChanges} disabled=${!canSaveChanges} loading=${savingChanges} tone="primary" size="sm" idleLabel="Save changes" loadingLabel="Saving..." className="transition-all" /> `} />

Primary Agent Model

${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}

${canToggleFullCatalog ? html`
` : null}
${renderPrimaryProviderContent()}
${otherProviders .filter((p) => kCoreProviders.has(p)) .map((provider) => renderProviderCard(provider))} ${showMoreProviders ? otherProviders .filter((p) => !kCoreProviders.has(p)) .map((provider) => renderProviderCard(provider)) : null} ${otherProviders.some((p) => !kCoreProviders.has(p)) ? html` ` : null} ${modelDirty && !hasSelectedProviderAuth ? html`

Set credentials for the selected provider before saving this model change.

` : null}
`; }; ================================================ FILE: lib/public/js/components/routes/agents-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { AgentsTab } from "../agents-tab/index.js"; const html = htm.bind(h); export const AgentsRoute = ({ agents = [], loading = false, saving = false, agentsActions = {}, selectedAgentId = "", activeTab = "overview", onSelectAgent = () => {}, onSelectTab = () => {}, onNavigateToBrowseFile = () => {}, onSetLocation = () => {}, }) => html` <${AgentsTab} agents=${agents} loading=${loading} saving=${saving} agentsActions=${agentsActions} selectedAgentId=${selectedAgentId} activeTab=${activeTab} onSelectAgent=${onSelectAgent} onSelectTab=${onSelectTab} onNavigateToBrowseFile=${onNavigateToBrowseFile} onSetLocation=${onSetLocation} /> `; ================================================ FILE: lib/public/js/components/routes/browse-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { FileViewer } from "../file-viewer/index.js"; const html = htm.bind(h); export const BrowseRoute = ({ activeBrowsePath = "", browseView = "edit", lineTarget = 0, lineEndTarget = 0, selectedBrowsePath = "", onNavigateToBrowseFile = () => {}, onEditSelectedBrowseFile = () => {}, onClearSelection = () => {}, }) => html`
<${FileViewer} filePath=${activeBrowsePath} isPreviewOnly=${false} browseView=${browseView} lineTarget=${lineTarget} lineEndTarget=${lineEndTarget} onRequestEdit=${(targetPath) => { const normalizedTargetPath = String(targetPath || ""); if (normalizedTargetPath && normalizedTargetPath !== selectedBrowsePath) { onNavigateToBrowseFile(normalizedTargetPath, { view: "edit" }); return; } onEditSelectedBrowseFile(); }} onRequestClearSelection=${onClearSelection} />
`; ================================================ FILE: lib/public/js/components/routes/chat-route.js ================================================ import { h } from "preact"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks"; import htm from "htm"; import { marked } from "marked"; import { authFetch } from "../../lib/api.js"; import { kChatSessionDraftsStorageKey } from "../../lib/storage-keys.js"; import { getSessionDisplayLabel } from "../../lib/session-keys.js"; import { showToast } from "../toast.js"; const html = htm.bind(h); const kWsReconnectMaxAttempts = 8; const kAutoscrollBottomThresholdPx = 40; const kChatDebugQueryFlag = "chatDebug"; const kComposerMaxLines = 5; const kComposerFontSizePx = 12; const kComposerLineHeight = 1.4; const kComposerPaddingYPx = 20; const resizeComposerTextarea = (element) => { if (!element) return; const linePx = kComposerFontSizePx * kComposerLineHeight; const minH = linePx + kComposerPaddingYPx; const maxH = linePx * kComposerMaxLines + kComposerPaddingYPx; element.style.height = "auto"; const next = Math.min(Math.max(element.scrollHeight, minH), maxH); element.style.height = `${next}px`; }; const buildMessage = ({ role = "assistant", content = "", createdAt = Date.now(), debugPayload = null, } = {}) => ({ id: crypto.randomUUID(), role, content: String(content || ""), createdAt: Number(createdAt) || Date.now(), debugPayload, }); const formatChatTime = (createdAt) => { const value = Number(createdAt || 0); if (!value) return ""; try { return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } catch { return ""; } }; const escapeHtmlForMarkdown = (value = "") => String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); const normalizeMarkdownInput = (value = "") => { const source = String(value || "").replace(/\r\n/g, "\n"); if (source.includes("\n")) return source; // Some runtimes persist escaped sequences in history payloads. return source.includes("\\n") ? source.replace(/\\n/g, "\n") : source; }; const normalizeListMarkers = (value = "") => String(value || "").replace(/^(\s*)\d+\.\s+/gm, "$1- "); const parseJsonMessage = (value = "") => { const source = String(value || "").trim(); if (!source) return null; if (!(source.startsWith("{") || source.startsWith("["))) return null; try { return JSON.parse(source); } catch { return null; } }; const extractToolCallsFromPayload = (payload = null) => { const normalizedPayload = payload && typeof payload === "object" ? payload : {}; if ( Array.isArray(normalizedPayload?.toolCalls) && normalizedPayload.toolCalls.length > 0 ) { return normalizedPayload.toolCalls; } const rawParts = Array.isArray(normalizedPayload?.rawMessage?.content) ? normalizedPayload.rawMessage.content : []; return rawParts .filter((part) => String(part?.type || "").toLowerCase() === "toolcall") .map((part) => ({ id: String(part?.id || ""), name: String(part?.name || ""), arguments: part?.arguments || null, partialJson: String(part?.partialJson || ""), })) .filter((toolCall) => toolCall.name || toolCall.id); }; const normalizeToolResult = (toolResult = null) => { if (!toolResult || typeof toolResult !== "object") return null; const rawMessage = toolResult?.rawMessage || toolResult; if (!rawMessage || typeof rawMessage !== "object") return null; const contentParts = Array.isArray(rawMessage?.content) ? rawMessage.content : []; const text = contentParts .map((part) => String(part?.text || "")) .filter((value) => value.length > 0) .join("\n") .trim(); return { toolCallId: String(rawMessage?.toolCallId || toolResult?.toolCallId || ""), toolName: String(rawMessage?.toolName || toolResult?.toolName || ""), text, isError: Boolean( rawMessage?.isError === true || toolResult?.isError === true || String(rawMessage?.status || "").toLowerCase() === "error", ), rawMessage, }; }; const buildToolMessage = ({ toolCall = null, toolResult = null, createdAt = Date.now(), debugPayload = null, } = {}) => { const normalizedToolCall = toolCall && typeof toolCall === "object" ? toolCall : {}; const name = String( normalizedToolCall?.name || toolResult?.toolName || "unknown", ); return buildMessage({ role: "tool", content: `Tool call: ${name}`, createdAt, debugPayload: debugPayload || ({ timestamp: createdAt, metadata: null, rawMessage: null, toolCalls: normalizedToolCall?.name || normalizedToolCall?.id ? [normalizedToolCall] : [], toolResult: toolResult || null, }), }); }; const renderMarkdownHtml = (value = "") => marked.parse( escapeHtmlForMarkdown(normalizeListMarkers(normalizeMarkdownInput(value))), { gfm: true, breaks: true, }, ); export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => { const [messagesBySession, setMessagesBySession] = useState({}); const [draft, setDraft] = useState(""); const [draftBySession, setDraftBySession] = useState(() => { try { const rawValue = localStorage.getItem(kChatSessionDraftsStorageKey); if (!rawValue) return {}; const parsed = JSON.parse(rawValue); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } }); const [sending, setSending] = useState(false); const [streaming, setStreaming] = useState(false); const [isConnected, setIsConnected] = useState(false); const [rawHistoryBySession, setRawHistoryBySession] = useState({}); const [debugEventsBySession, setDebugEventsBySession] = useState({}); const [activeRunBySession, setActiveRunBySession] = useState({}); const [connectionError, setConnectionError] = useState(""); const [historyLoading, setHistoryLoading] = useState(false); const [assistantStreamStarted, setAssistantStreamStarted] = useState(false); const wsRef = useRef(null); const threadRef = useRef(null); const composerRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectAttemptsRef = useRef(0); const selectedSessionKeyRef = useRef(selectedSessionKey); const realtimeDisabledRef = useRef(false); const shouldAutoScrollRef = useRef(true); const appendDebugEvent = useCallback((sessionKey, label, payload) => { const normalizedSessionKey = String( sessionKey || selectedSessionKeyRef.current || "", ); if (!normalizedSessionKey) return; const nextEvent = { id: crypto.randomUUID(), at: Date.now(), label: String(label || ""), payload: payload ?? null, }; setDebugEventsBySession((currentMap) => { const existing = currentMap[normalizedSessionKey] || []; const nextList = [...existing, nextEvent].slice(-30); return { ...currentMap, [normalizedSessionKey]: nextList, }; }); }, []); useEffect(() => { selectedSessionKeyRef.current = selectedSessionKey; }, [selectedSessionKey]); useEffect(() => { setAssistantStreamStarted(false); }, [selectedSessionKey]); useLayoutEffect(() => { resizeComposerTextarea(composerRef.current); }, [draft, selectedSessionKey]); useEffect(() => { if (!selectedSessionKey) return; setDraft(String(draftBySession[selectedSessionKey] || "")); }, [draftBySession, selectedSessionKey]); useEffect(() => { try { localStorage.setItem( kChatSessionDraftsStorageKey, JSON.stringify(draftBySession), ); } catch {} }, [draftBySession]); const selectedSession = useMemo( () => sessions.find( (sessionRow) => String(sessionRow?.key || "") === String(selectedSessionKey || ""), ) || null, [selectedSessionKey, sessions], ); const chatDebugEnabled = useMemo(() => { try { const params = new URLSearchParams(window.location.search || ""); return params.get(kChatDebugQueryFlag) === "1"; } catch { return false; } }, []); const messages = useMemo( () => messagesBySession[selectedSessionKey] || [], [messagesBySession, selectedSessionKey], ); useEffect(() => { let mounted = true; const connect = () => { if (realtimeDisabledRef.current) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket( `${protocol}//${window.location.host}/api/ws/chat`, ); wsRef.current = ws; ws.onopen = () => { if (!mounted) return; setIsConnected(true); setConnectionError(""); reconnectAttemptsRef.current = 0; const currentSessionKey = String(selectedSessionKeyRef.current || ""); if (currentSessionKey) { setHistoryLoading(true); ws.send( JSON.stringify({ type: "history", sessionKey: currentSessionKey, }), ); } }; ws.onclose = () => { if (!mounted) return; setIsConnected(false); setStreaming(false); setSending(false); setAssistantStreamStarted(false); setHistoryLoading(false); if (realtimeDisabledRef.current) return; if (reconnectAttemptsRef.current >= kWsReconnectMaxAttempts) return; const delayMs = Math.min( 1000 * 2 ** reconnectAttemptsRef.current, 5000, ); reconnectAttemptsRef.current += 1; setConnectionError("Realtime chat socket disconnected."); reconnectTimerRef.current = setTimeout(connect, delayMs); }; ws.onerror = () => { if (!mounted) return; setIsConnected(false); setHistoryLoading(false); setConnectionError("Realtime chat socket failed to connect."); }; ws.onmessage = (event) => { let payload = null; try { payload = JSON.parse(String(event?.data || "")); } catch { return; } if (!payload || typeof payload !== "object") return; appendDebugEvent( String(payload.sessionKey || selectedSessionKeyRef.current || ""), `ws:${String(payload.type || "unknown")}`, payload, ); if (payload.type === "history") { const historySessionKey = String(payload.sessionKey || ""); if (!historySessionKey) return; const historyMessages = ( Array.isArray(payload.messages) ? payload.messages : [] ) .map((messageRow) => buildMessage({ role: String(messageRow?.role || "assistant"), content: String(messageRow?.content || ""), createdAt: Number(messageRow?.timestamp) || Date.now(), debugPayload: messageRow || null, }), ) .filter( (messageRow) => String(messageRow.content || "").trim() || extractToolCallsFromPayload(messageRow?.debugPayload).length > 0, ); setMessagesBySession((currentMap) => ({ ...currentMap, [historySessionKey]: historyMessages, })); setRawHistoryBySession((currentMap) => ({ ...currentMap, [historySessionKey]: payload.rawHistory || null, })); setHistoryLoading(false); return; } if (payload.type === "chunk") { const chunkSessionKey = String( payload.sessionKey || selectedSessionKeyRef.current || "", ); const messageId = String(payload.messageId || ""); const chunkText = String(payload.content || ""); if (!chunkSessionKey || !messageId) return; setSending(false); setStreaming(true); setAssistantStreamStarted(true); setMessagesBySession((currentMap) => { const currentMessages = currentMap[chunkSessionKey] || []; const lastMessage = currentMessages[currentMessages.length - 1]; if ( lastMessage && lastMessage.role === "assistant" && String(lastMessage.id || "") === messageId ) { return { ...currentMap, [chunkSessionKey]: [ ...currentMessages.slice(0, -1), { ...lastMessage, content: `${String(lastMessage.content || "")}${chunkText}`, debugPayload: { ...(lastMessage?.debugPayload || {}), source: "stream", messageId, sessionKey: chunkSessionKey, chunkCount: Number(lastMessage?.debugPayload?.chunkCount || 1) + 1, lastChunk: chunkText, }, }, ], }; } return { ...currentMap, [chunkSessionKey]: [ ...currentMessages, { id: messageId, role: "assistant", content: chunkText, createdAt: Date.now(), debugPayload: { source: "stream", messageId, sessionKey: chunkSessionKey, chunkCount: 1, lastChunk: chunkText, }, }, ], }; }); return; } if (payload.type === "tool") { const toolSessionKey = String( payload.sessionKey || selectedSessionKeyRef.current || "", ); if (!toolSessionKey) return; setSending(false); setAssistantStreamStarted(true); const toolPhase = String(payload.phase || "").toLowerCase(); const toolCall = payload?.toolCall && typeof payload.toolCall === "object" ? payload.toolCall : null; const toolResult = payload?.toolResult && typeof payload.toolResult === "object" ? payload.toolResult : null; const toolCallId = String( toolCall?.id || toolResult?.toolCallId || payload?.toolCallId || "", ); const toolTimestamp = Number(payload.timestamp) || Date.now(); setMessagesBySession((currentMap) => { const currentMessages = currentMap[toolSessionKey] || []; if (toolPhase === "result") { let matched = false; const nextMessages = currentMessages.map((messageRow) => { if (matched || messageRow.role !== "tool") return messageRow; const messageToolCalls = extractToolCallsFromPayload( messageRow.debugPayload, ); const messageToolCallId = String(messageToolCalls?.[0]?.id || ""); const messageToolName = String(messageToolCalls?.[0]?.name || ""); const resultToolName = String(toolResult?.toolName || ""); const hasResultAlready = Boolean( normalizeToolResult(messageRow?.debugPayload?.toolResult), ); const shouldMatchById = toolCallId && messageToolCallId && messageToolCallId === toolCallId; const shouldMatchByNameFallback = !toolCallId && !messageToolCallId && resultToolName && messageToolName === resultToolName && !hasResultAlready; if (!shouldMatchById && !shouldMatchByNameFallback) { return messageRow; } matched = true; return { ...messageRow, debugPayload: { ...(messageRow.debugPayload || {}), toolResult: toolResult || null, rawEvent: payload?.rawEvent || null, }, }; }); if (matched) { return { ...currentMap, [toolSessionKey]: nextMessages, }; } } if (toolPhase === "call" && toolCall) { const duplicateCall = currentMessages.some((messageRow) => { if (messageRow.role !== "tool") return false; const existingCall = extractToolCallsFromPayload( messageRow.debugPayload, )[0]; if (!existingCall) return false; const existingId = String(existingCall?.id || ""); if (toolCallId && existingId && existingId === toolCallId) { return true; } const existingName = String(existingCall?.name || ""); const incomingName = String(toolCall?.name || ""); return !toolCallId && existingName && incomingName && existingName === incomingName; }); if (duplicateCall) return currentMap; return { ...currentMap, [toolSessionKey]: [ ...currentMessages, buildToolMessage({ toolCall, createdAt: toolTimestamp, debugPayload: { timestamp: toolTimestamp, metadata: null, rawMessage: null, toolCalls: [toolCall], toolResult: null, rawEvent: payload?.rawEvent || null, }, }), ], }; } if (toolPhase === "result" && toolResult) { return { ...currentMap, [toolSessionKey]: [ ...currentMessages, buildToolMessage({ toolCall: toolCall || { id: String(toolResult?.toolCallId || ""), name: String(toolResult?.toolName || "unknown"), arguments: null, partialJson: "", }, toolResult, createdAt: toolTimestamp, debugPayload: { timestamp: toolTimestamp, metadata: null, rawMessage: null, toolCalls: toolCall ? [toolCall] : [], toolResult, rawEvent: payload?.rawEvent || null, }, }), ], }; } return currentMap; }); return; } if (payload.type === "started") { const nextSessionKey = String( payload.sessionKey || selectedSessionKeyRef.current || "", ); const runId = String(payload.runId || ""); if (!nextSessionKey || !runId) return; setSending(false); setActiveRunBySession((currentMap) => ({ ...currentMap, [nextSessionKey]: runId, })); return; } if (payload.type === "done") { const doneSessionKey = String( payload.sessionKey || selectedSessionKeyRef.current || "", ); if (doneSessionKey) { setActiveRunBySession((currentMap) => { const nextMap = { ...currentMap }; delete nextMap[doneSessionKey]; return nextMap; }); } setSending(false); setStreaming(false); setAssistantStreamStarted(false); setHistoryLoading(false); if (doneSessionKey && ws && ws.readyState === 1) { setHistoryLoading(true); appendDebugEvent(doneSessionKey, "ws:history-request-after-done", { type: "history", sessionKey: doneSessionKey, }); ws.send( JSON.stringify({ type: "history", sessionKey: doneSessionKey, }), ); } return; } if (payload.type === "error") { setSending(false); setStreaming(false); setAssistantStreamStarted(false); setHistoryLoading(false); const errorSessionKey = String( payload.sessionKey || selectedSessionKeyRef.current || "", ); if (errorSessionKey) { setActiveRunBySession((currentMap) => { const nextMap = { ...currentMap }; delete nextMap[errorSessionKey]; return nextMap; }); setMessagesBySession((currentMap) => ({ ...currentMap, [errorSessionKey]: [ ...(currentMap[errorSessionKey] || []), buildMessage({ role: "assistant", content: String(payload.message || "").trim() || "Something went wrong.", }), ], })); } if (payload.message) showToast(String(payload.message), "error"); } }; }; connect(); return () => { mounted = false; if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } const ws = wsRef.current; wsRef.current = null; if (ws) ws.close(); }; }, []); useEffect(() => { if (!selectedSessionKey) return; const ws = wsRef.current; if (ws && ws.readyState === 1) { setHistoryLoading(true); appendDebugEvent(selectedSessionKey, "ws:history-request", { type: "history", sessionKey: selectedSessionKey, }); ws.send( JSON.stringify({ type: "history", sessionKey: selectedSessionKey, }), ); return; } // Fallback for environments where websocket upgrade is unavailable: // load history over HTTP so the UI can still show prior messages. let cancelled = false; const loadHistory = async () => { try { setHistoryLoading(true); const response = await authFetch( `/api/chat/history?sessionKey=${encodeURIComponent(selectedSessionKey)}`, ); const payload = await response.json(); if (cancelled) return; if (!response.ok || payload?.ok === false) { throw new Error(payload?.error || "Could not load chat history"); } appendDebugEvent(selectedSessionKey, "http:history-response", payload); const historyMessages = ( Array.isArray(payload.messages) ? payload.messages : [] ) .map((messageRow) => buildMessage({ role: String(messageRow?.role || "assistant"), content: String(messageRow?.content || ""), createdAt: Number(messageRow?.timestamp) || Date.now(), debugPayload: messageRow || null, }), ) .filter( (messageRow) => String(messageRow.content || "").trim() || extractToolCallsFromPayload(messageRow?.debugPayload).length > 0, ); setMessagesBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: historyMessages, })); setRawHistoryBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: payload.rawHistory || null, })); if (!isConnected) { // If HTTP history works while WS is down, stop noisy reconnect loops. realtimeDisabledRef.current = true; if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } const ws = wsRef.current; if (ws) ws.close(); setConnectionError("Realtime unavailable; using HTTP fallback."); } } catch (err) { if (cancelled) return; const errorMessage = err.message || "Could not load chat history."; appendDebugEvent(selectedSessionKey, "http:history-error", { error: errorMessage, }); if ( errorMessage.toLowerCase().includes("runtime unavailable") || errorMessage.toLowerCase().includes("websocket unavailable") ) { realtimeDisabledRef.current = true; if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } const ws = wsRef.current; if (ws) ws.close(); setConnectionError( "Chat runtime unavailable (missing server dependency).", ); } else { setConnectionError(errorMessage); } } finally { if (!cancelled) setHistoryLoading(false); } }; loadHistory(); return () => { cancelled = true; }; }, [isConnected, selectedSessionKey]); const handleThreadScroll = useCallback(() => { const threadElement = threadRef.current; if (!threadElement) return; const distanceFromBottom = threadElement.scrollHeight - threadElement.scrollTop - threadElement.clientHeight; shouldAutoScrollRef.current = distanceFromBottom <= kAutoscrollBottomThresholdPx; }, []); useEffect(() => { const threadElement = threadRef.current; if (!threadElement) return; if (!shouldAutoScrollRef.current) return; threadElement.scrollTop = threadElement.scrollHeight; }, [messages, historyLoading, streaming]); const handleDraftInput = useCallback( (event) => { const nextValue = String(event?.target?.value || ""); setDraft(nextValue); if (!selectedSessionKey) return; setDraftBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: nextValue, })); }, [selectedSessionKey], ); const handleSend = useCallback(() => { const messageText = String(draft || "").trim(); const ws = wsRef.current; if (!messageText || !selectedSessionKey || sending || streaming) return; if (!ws || ws.readyState !== 1) { showToast( "Chat websocket is unavailable in this environment.", "warning", ); return; } const userMessage = buildMessage({ role: "user", content: messageText, debugPayload: { source: "composer", type: "message", content: messageText, sessionKey: selectedSessionKey, }, }); setDraft(""); setDraftBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: "", })); setAssistantStreamStarted(false); setSending(true); setMessagesBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: [ ...(currentMap[selectedSessionKey] || []), userMessage, ], })); setStreaming(true); ws.send( JSON.stringify({ type: "message", content: messageText, sessionKey: selectedSessionKey, }), ); appendDebugEvent(selectedSessionKey, "ws:message-request", { type: "message", content: messageText, sessionKey: selectedSessionKey, }); }, [appendDebugEvent, draft, selectedSessionKey, sending, streaming]); const handleStop = useCallback(() => { const ws = wsRef.current; if (!ws || ws.readyState !== 1 || !selectedSessionKey) return; ws.send( JSON.stringify({ type: "stop", sessionKey: selectedSessionKey, }), ); appendDebugEvent(selectedSessionKey, "ws:stop-request", { type: "stop", sessionKey: selectedSessionKey, }); setStreaming(false); setSending(false); setAssistantStreamStarted(false); }, [appendDebugEvent, selectedSessionKey]); const handleComposerKeyDown = useCallback( (event) => { if (event.key !== "Enter") return; if (event.shiftKey) return; if (event.isComposing) return; event.preventDefault(); handleSend(); }, [handleSend], ); const rawHistory = selectedSessionKey ? rawHistoryBySession[selectedSessionKey] : null; const debugEvents = selectedSessionKey ? debugEventsBySession[selectedSessionKey] || [] : []; return html`
Chat
${getSessionDisplayLabel(selectedSession) || "Pick a session in the sidebar"}
${connectionError ? html`
${connectionError}
` : null}
${!selectedSessionKey ? html`
Select a session to begin chatting.
` : historyLoading ? html`
Loading history...
` : messages.length === 0 ? html`
Start a message in this session.
` : messages.map( (message) => html` ${(() => { const toolCalls = extractToolCallsFromPayload( message.debugPayload, ); const hasVisibleContent = String(message.content || "").trim().length > 0; const isToolMessage = message.role === "tool"; const shouldRenderContent = hasVisibleContent && !isToolMessage && !( toolCalls.length > 0 && String(message.content || "").startsWith( "Tool calls:", ) ); const primaryToolCall = toolCalls[0] || null; const matchedResult = normalizeToolResult( message?.debugPayload?.toolResult || null, ); return html`
${!isToolMessage ? html`
${message.role === "user" ? "You" : "Agent"} ${formatChatTime(message.createdAt)}
` : null} ${isToolMessage && primaryToolCall ? html`
🛠️ ${String( primaryToolCall?.name || "unknown", )} ${formatChatTime( message.createdAt, )}
Payload
${JSON.stringify(
                                        {
                                          id:
                                            String(primaryToolCall?.id || "") ||
                                            null,
                                          name:
                                            String(
                                              primaryToolCall?.name || "",
                                            ) || null,
                                          arguments:
                                            primaryToolCall?.arguments || null,
                                          partialJson:
                                            String(
                                              primaryToolCall?.partialJson ||
                                                "",
                                            ) || null,
                                        },
                                        null,
                                        2,
                                      )}
${matchedResult ? html`
Result${matchedResult.isError ? " (error)" : ""}
${JSON.stringify(
                                              {
                                                toolCallId:
                                                  matchedResult.toolCallId,
                                                toolName:
                                                  matchedResult.toolName,
                                                text: matchedResult.text || "",
                                                isError: matchedResult.isError,
                                                rawMessage:
                                                  matchedResult.rawMessage ||
                                                  null,
                                              },
                                              null,
                                              2,
                                            )}
` : null}
` : null} ${shouldRenderContent ? (() => { const parsedJson = parseJsonMessage( message.content, ); if (parsedJson) { return html`
${JSON.stringify(parsedJson, null, 2)}
`; } return html`
`; })() : null} ${!isToolMessage ? html`
JSON
${JSON.stringify(
                                      message.debugPayload || {
                                        role: message.role,
                                        content: message.content,
                                        createdAt: message.createdAt,
                                      },
                                      null,
                                      2,
                                    )}
` : null}
`; })()} `, )} ${selectedSessionKey && (sending || streaming) && !assistantStreamStarted ? html`
` : null} ${selectedSessionKey ? chatDebugEnabled ? html`
Raw history JSON
${JSON.stringify(rawHistory || null, null, 2)}
Inbound event log
${JSON.stringify(debugEvents, null, 2)}
` : null : null}
${streaming ? html` ` : null}
`; }; ================================================ FILE: lib/public/js/components/routes/cron-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { CronTab } from "../cron-tab/index.js"; const html = htm.bind(h); export const CronRoute = ({ jobId = "", onSetLocation = () => {} }) => html` <${CronTab} jobId=${jobId} onSetLocation=${onSetLocation} /> `; ================================================ FILE: lib/public/js/components/routes/doctor-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { DoctorTab } from "../doctor/index.js"; const html = htm.bind(h); export const DoctorRoute = ({ onNavigateToBrowseFile = () => {} }) => html`
<${DoctorTab} isActive=${true} onOpenFile=${(relativePath, options = {}) => { const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`; onNavigateToBrowseFile(browsePath, { view: "edit", ...(options.line ? { line: options.line } : {}), ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}), }); }} />
`; ================================================ FILE: lib/public/js/components/routes/envars-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { Envars } from "../envars.js"; const html = htm.bind(h); export const EnvarsRoute = ({ onRestartRequired = () => {} }) => html` <${Envars} onRestartRequired=${onRestartRequired} /> `; ================================================ FILE: lib/public/js/components/routes/general-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { GeneralTab } from "../general/index.js"; const html = htm.bind(h); export const GeneralRoute = ({ statusData = null, watchdogData = null, doctorStatusData = null, agents = [], doctorWarningDismissedUntilMs = 0, onRefreshStatuses = () => {}, onSetLocation = () => {}, onNavigate = () => {}, restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, onRestartRequired = () => {}, onDismissDoctorWarning = () => {}, }) => html`
<${GeneralTab} statusData=${statusData} watchdogData=${watchdogData} doctorStatusData=${doctorStatusData} agents=${agents} doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs} onRefreshStatuses=${onRefreshStatuses} onSwitchTab=${(nextTab) => onSetLocation(`/${nextTab}`)} onNavigate=${onNavigate} onOpenGmailWebhook=${() => onSetLocation("/webhooks/gmail")} isActive=${true} restartingGateway=${restartingGateway} onRestartGateway=${onRestartGateway} restartSignal=${restartSignal} onRestartRequired=${onRestartRequired} onDismissDoctorWarning=${onDismissDoctorWarning} />
`; ================================================ FILE: lib/public/js/components/routes/index.js ================================================ export { AgentsRoute } from "./agents-route.js"; export { BrowseRoute } from "./browse-route.js"; export { ChatRoute } from "./chat-route.js"; export { CronRoute } from "./cron-route.js"; export { DoctorRoute } from "./doctor-route.js"; export { EnvarsRoute } from "./envars-route.js"; export { GeneralRoute } from "./general-route.js"; export { ModelsRoute } from "./models-route.js"; export { NodesRoute } from "./nodes-route.js"; export { ProvidersRoute } from "./providers-route.js"; export { RouteRedirect } from "./route-redirect.js"; export { TelegramRoute } from "./telegram-route.js"; export { UsageRoute } from "./usage-route.js"; export { WatchdogRoute } from "./watchdog-route.js"; export { WebhooksRoute } from "./webhooks-route.js"; ================================================ FILE: lib/public/js/components/routes/models-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { Models } from "../models-tab/index.js"; const html = htm.bind(h); export const ModelsRoute = ({ onRestartRequired = () => {} }) => html` <${Models} onRestartRequired=${onRestartRequired} /> `; ================================================ FILE: lib/public/js/components/routes/nodes-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { NodesTab } from "../nodes-tab/index.js"; const html = htm.bind(h); export const NodesRoute = ({ onRestartRequired = () => {} }) => html`
<${NodesTab} onRestartRequired=${onRestartRequired} />
`; ================================================ FILE: lib/public/js/components/routes/providers-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { Providers } from "../providers.js"; const html = htm.bind(h); export const ProvidersRoute = ({ onRestartRequired = () => {} }) => html`
<${Providers} onRestartRequired=${onRestartRequired} />
`; ================================================ FILE: lib/public/js/components/routes/route-redirect.js ================================================ import { useEffect } from "preact/hooks"; import { useLocation } from "wouter-preact"; export const RouteRedirect = ({ to }) => { const [, setLocation] = useLocation(); useEffect(() => { setLocation(to); }, [to, setLocation]); return null; }; ================================================ FILE: lib/public/js/components/routes/telegram-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { TelegramWorkspace } from "../telegram-workspace/index.js"; const html = htm.bind(h); export const TelegramRoute = ({ accountId = "default", onBack = () => {} }) => html`
<${TelegramWorkspace} key=${accountId} accountId=${accountId} onBack=${onBack} />
`; ================================================ FILE: lib/public/js/components/routes/usage-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { UsageTab } from "../usage-tab/index.js"; const html = htm.bind(h); export const UsageRoute = ({ sessionId = "", onSetLocation = () => {} }) => html`
<${UsageTab} sessionId=${sessionId} onSelectSession=${(id) => onSetLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} onBackToSessions=${() => onSetLocation("/usage")} />
`; ================================================ FILE: lib/public/js/components/routes/watchdog-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { WatchdogTab } from "../watchdog-tab/index.js"; const html = htm.bind(h); export const WatchdogRoute = ({ statusData = null, watchdogStatus = null, onRefreshStatuses = () => {}, restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, }) => html`
<${WatchdogTab} gatewayStatus=${statusData?.gateway || null} openclawVersion=${statusData?.openclawVersion || null} watchdogStatus=${watchdogStatus} onRefreshStatuses=${onRefreshStatuses} restartingGateway=${restartingGateway} onRestartGateway=${onRestartGateway} restartSignal=${restartSignal} />
`; ================================================ FILE: lib/public/js/components/routes/webhooks-route.js ================================================ import { h } from "preact"; import htm from "htm"; import { Webhooks } from "../webhooks/index.js"; const html = htm.bind(h); export const WebhooksRoute = ({ hookName = "", routeHistoryRef = null, getCurrentPath = () => "", onSetLocation = () => {}, onRestartRequired = () => {}, onNavigateToBrowseFile = () => {}, }) => { const handleBackToList = () => { const historyStack = routeHistoryRef?.current || []; const hasPreviousRoute = historyStack.length > 1; if (!hasPreviousRoute) { onSetLocation("/webhooks"); return; } const currentPath = getCurrentPath(); window.history.back(); window.setTimeout(() => { if (getCurrentPath() === currentPath) { onSetLocation("/webhooks"); } }, 180); }; return html`
<${Webhooks} selectedHookName=${hookName} onSelectHook=${(name) => onSetLocation(`/webhooks/${encodeURIComponent(name)}`)} onBackToList=${handleBackToList} onRestartRequired=${onRestartRequired} onOpenFile=${(relativePath) => onNavigateToBrowseFile(String(relativePath || "").trim(), { view: "edit" })} />
`; }; ================================================ FILE: lib/public/js/components/scope-picker.js ================================================ import { h } from 'preact'; import { useState } from 'preact/hooks'; import htm from 'htm'; const html = htm.bind(h); export const SERVICES = [ { key: 'gmail', icon: '📧', label: 'Gmail', defaultRead: true, defaultWrite: false }, { key: 'calendar', icon: '📅', label: 'Calendar', defaultRead: true, defaultWrite: true }, { key: 'drive', icon: '📁', label: 'Drive', defaultRead: true, defaultWrite: false }, { key: 'sheets', icon: '📊', label: 'Sheets', defaultRead: true, defaultWrite: false }, { key: 'docs', icon: '📝', label: 'Docs', defaultRead: true, defaultWrite: false }, { key: 'tasks', icon: '✅', label: 'Tasks', defaultRead: false, defaultWrite: false }, { key: 'contacts', icon: '👤', label: 'Contacts', defaultRead: false, defaultWrite: false }, { key: 'meet', icon: '🎥', label: 'Meet', defaultRead: false, defaultWrite: false }, ]; const API_ENABLE_URLS = { gmail: 'gmail.googleapis.com', calendar: 'calendar-json.googleapis.com', tasks: 'tasks.googleapis.com', drive: 'drive.googleapis.com', contacts: 'people.googleapis.com', sheets: 'sheets.googleapis.com', docs: 'docs.googleapis.com', meet: 'meet.googleapis.com', }; function getApiEnableUrl(svc) { return `https://console.developers.google.com/apis/api/${API_ENABLE_URLS[svc] || ''}/overview`; } export function ScopePicker({ scopes, onToggle, apiStatus, loading }) { const [showAll, setShowAll] = useState(false); const status = apiStatus || {}; const kVisibleCount = 5; const hasMore = SERVICES.length > kVisibleCount; const visibleServices = showAll ? SERVICES : SERVICES.slice(0, kVisibleCount); return html`
${visibleServices.map(s => { const readOn = scopes.includes(`${s.key}:read`); const writeOn = scopes.includes(`${s.key}:write`); const api = status[s.key]; let apiIndicator = null; if (loading && !api && (readOn || writeOn)) { apiIndicator = html``; } else if (api) { if (api.status === 'ok') { apiIndicator = html`API ✓`; } else if (api.status === 'not_enabled') { apiIndicator = html`Enable API`; } else if (api.status === 'error') { apiIndicator = html`Enable API`; } } return html`
${s.icon} ${s.label}
${apiIndicator}
`; })} ${hasMore ? html` ` : null}
`; } // Returns new scopes array after toggling, with read/write dependency logic export function toggleScopeLogic(scopes, scope) { const isActive = scopes.includes(scope); let next = isActive ? scopes.filter(s => s !== scope) : [...scopes, scope]; if (scope.endsWith(':write') && !isActive) { // enabling write → also enable read const readScope = scope.replace(':write', ':read'); if (!next.includes(readScope)) next.push(readScope); } if (scope.endsWith(':read') && isActive) { // disabling read → also disable write const writeScope = scope.replace(':read', ':write'); next = next.filter(s => s !== writeScope); } return next; } // Get default scopes from SERVICES export function getDefaultScopes() { const scopes = []; for (const s of SERVICES) { if (s.defaultRead) scopes.push(`${s.key}:read`); if (s.defaultWrite) scopes.push(`${s.key}:write`); } return scopes; } ================================================ FILE: lib/public/js/components/secret-input.js ================================================ import { h } from "preact"; import { useState } from "preact/hooks"; import htm from "htm"; import { LoadingSpinner } from "./loading-spinner.js"; const html = htm.bind(h); /** * Reusable input with show/hide toggle for secret values. * * Props: * value, onInput, placeholder, inputClass, disabled * isSecret – treat as password field (default true) */ export const SecretInput = ({ value = "", onInput, onBlur, placeholder = "", inputClass = "", disabled = false, loading = false, isSecret = true, }) => { const [visible, setVisible] = useState(false); const showToggle = isSecret; const isDisabled = disabled || loading; return html`
${loading ? html`<${LoadingSpinner} className="h-3 w-3 text-fg-muted shrink-0" />` : null} ${showToggle ? html`` : null}
`; }; ================================================ FILE: lib/public/js/components/segmented-control.js ================================================ import { h } from "preact"; import htm from "htm"; import { Tooltip } from "./tooltip.js"; const html = htm.bind(h); /** * Reusable segmented control (pill toggle). * * @param {Object} props * @param {Array<{label:string, value:*, title?:string}>} props.options * @param {*} props.value Currently selected value. * @param {Function} props.onChange Called with the new value on click. * @param {string} [props.className] Extra classes on the wrapper. * @param {"sm"|"lg"} [props.size] Visual size variant. * @param {boolean} [props.fullWidth] Stretch wrapper and options to 100%. */ export const SegmentedControl = ({ options = [], value, onChange = () => {}, className = "", size = "sm", fullWidth = false, }) => html`
${options.map( (option) => { const btn = html` `; return option.title ? html`<${Tooltip} text=${option.title} delay=${1000} widthClass="w-auto max-w-64 whitespace-normal">${btn}` : btn; }, )}
`; ================================================ FILE: lib/public/js/components/session-select-field.js ================================================ import { h } from "preact"; import htm from "htm"; import { getSessionDisplayLabel, getSessionRowKey, } from "../lib/session-keys.js"; const html = htm.bind(h); export const SessionSelectField = ({ label = "Send to session", sessions = [], selectedSessionKey = "", onChangeSessionKey = () => {}, disabled = false, loading = false, error = "", allowNone = false, noneValue = "__none__", noneLabel = "None", emptyOptionLabel = "No sessions available", helperText = "", emptyStateText = "", loadingLabel = "Loading sessions...", containerClassName = "space-y-2", labelClassName = "text-xs text-fg-muted", selectClassName = "w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted", helperClassName = "text-xs text-fg-muted", statusClassName = "text-xs text-fg-muted", errorClassName = "text-xs text-status-error-muted", }) => { const resolvedValue = selectedSessionKey || (allowNone ? noneValue : ""); const isDisabled = disabled || loading; return html`
${label ? html`` : null} ${helperText ? html`
${helperText}
` : null} ${error ? html`
${error}
` : null} ${ !loading && !error && emptyStateText && sessions.length === 0 ? html`
${emptyStateText}
` : null }
`; }; ================================================ FILE: lib/public/js/components/sidebar-git-panel.js ================================================ import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; import htm from "htm"; import { fetchBrowseGitSummary, syncBrowseChanges } from "../lib/api.js"; import { formatLocaleDateTime } from "../lib/format.js"; import { ActionButton } from "./action-button.js"; import { GitBranchLineIcon, GithubFillIcon } from "./icons.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { showToast } from "./toast.js"; const html = htm.bind(h); const kRefreshMs = 10000; const kSyncCommitFileNameLimit = 4; const kCommitHistoryLimit = 12; const formatCommitTime = (unixSeconds) => { return formatLocaleDateTime(unixSeconds, { fallback: "", valueIsUnixSeconds: true, }); }; const getRepoName = (summary) => { const slug = String(summary?.repoSlug || "").trim(); if (slug) return slug; const pathValue = String(summary?.repoPath || ""); const segment = pathValue.split("/").filter(Boolean).pop(); return segment || "repo"; }; const getChangedFilePresentation = (changedFile) => { const statusKind = String(changedFile?.statusKind || "M").toUpperCase(); if (statusKind === "U") { return { statusLabel: "U", statusClass: "is-untracked", rowClass: "is-clickable", canOpen: true, }; } if (statusKind === "D") { return { statusLabel: "D", statusClass: "is-deleted", rowClass: "is-clickable", canOpen: true, }; } return { statusLabel: "M", statusClass: "is-modified", rowClass: "is-clickable", canOpen: true, }; }; const formatDelta = (value, prefix) => { if (value === null || value === undefined || value === "") return ""; const numericValue = Number(value); if (!Number.isFinite(numericValue) || numericValue <= 0) return ""; return `${prefix}${numericValue}`; }; const isDirectoryChangePath = (changedPath, statusKind) => { const safePath = String(changedPath || "").trim(); const safeStatusKind = String(statusKind || "").toUpperCase(); if (!safePath) return false; if (safePath.endsWith("/")) return true; return safeStatusKind === "U" && safePath.endsWith("\\"); }; const getRemoteSyncPresentation = (summary) => { const safeState = String(summary?.syncState || "").trim(); const aheadCount = Number(summary?.aheadCount) || 0; const behindCount = Number(summary?.behindCount) || 0; if (safeState === "ahead") { return { label: "↑", title: `Ahead by ${aheadCount}`, className: "is-ahead", }; } if (safeState === "behind") { return { label: "↓", title: `Behind by ${behindCount}`, className: "is-behind", }; } if (safeState === "diverged") { return { label: "↕", title: `Diverged (${aheadCount} ahead, ${behindCount} behind)`, className: "is-diverged", }; } if (safeState === "upstream-gone") { return { label: "!", title: "Upstream missing", className: "is-upstream-gone", }; } if (safeState === "no-upstream" || !summary?.hasUpstream) { return { label: "!", title: "Not linked", className: "is-no-upstream", }; } return { label: "", title: "Up to date", className: "is-up-to-date", }; }; const buildSyncCommitMessage = (summary) => { const changedFiles = Array.isArray(summary?.changedFiles) ? summary.changedFiles : []; const changedFilesCount = Number(summary?.changedFilesCount) || 0; const filePaths = changedFiles .map((file) => String(file?.path || "").trim()) .filter(Boolean); const totalCount = changedFilesCount || filePaths.length; if (totalCount <= 0) return "sync changes"; const fileNames = filePaths .map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath); const uniqueFileNames = Array.from(new Set(fileNames)); if (uniqueFileNames.length <= 0) { const noun = totalCount === 1 ? "file" : "files"; return `Edited ${totalCount} ${noun}`; } const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit); const remainingCount = Math.max(0, totalCount - shownFileNames.length); const noun = totalCount === 1 ? "file" : "files"; const suffix = remainingCount > 0 ? ` +${remainingCount} more` : ""; return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`; }; export const SidebarGitPanel = ({ onSelectFile = () => {}, isActive = true, }) => { const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(""); const [summary, setSummary] = useState(null); useEffect(() => { if (!isActive) return () => {}; let active = true; let intervalId = null; const loadSummary = async () => { if (!active) return; try { const data = await fetchBrowseGitSummary(); if (!active) return; setSummary(data); setError(""); } catch (nextError) { if (!active) return; setError(nextError.message || "Could not load git summary"); } finally { if (active) setLoading(false); } }; const handleFileSaved = () => { loadSummary(); }; loadSummary(); intervalId = window.setInterval(loadSummary, kRefreshMs); window.addEventListener("alphaclaw:browse-file-saved", handleFileSaved); return () => { active = false; if (intervalId) window.clearInterval(intervalId); window.removeEventListener("alphaclaw:browse-file-saved", handleFileSaved); }; }, [isActive]); if (loading) { return html` `; } if (error) { return html``; } if (!summary?.isRepo) { return html` `; } const hasUncommittedChanges = (summary.changedFiles || []).length > 0; const aheadCount = Number(summary?.aheadCount) || 0; const canSyncChanges = hasUncommittedChanges || aheadCount > 0; const remoteSync = getRemoteSyncPresentation(summary); const handleSyncChanges = async () => { if (!canSyncChanges || syncing) return; try { setSyncing(true); const commitMessage = buildSyncCommitMessage(summary); const syncResult = await syncBrowseChanges(commitMessage); if (syncResult?.committed || syncResult?.pushed) { window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced")); showToast(syncResult.message || "Changes synced", "success"); } else { showToast(syncResult?.message || "No changes to sync", "info"); } const nextSummary = await fetchBrowseGitSummary(); setSummary(nextSummary); setError(""); } catch (syncError) { showToast(syncError.message || "Could not sync changes", "error"); } finally { setSyncing(false); } }; return html` `; }; ================================================ FILE: lib/public/js/components/sidebar.js ================================================ import { h } from "preact"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import htm from "htm"; import { AddLineIcon, AlarmLineIcon, BarChartLineIcon, Brain2LineIcon, BracesLineIcon, Chat4LineIcon, ChevronDownIcon, ComputerLineIcon, EyeLineIcon, FolderLineIcon, HomeLineIcon, PulseLineIcon, RobotLineIcon, SignalTowerLineIcon, } from "./icons.js"; import { FileTree } from "./file-tree.js"; import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; import { UpdateActionButton } from "./update-action-button.js"; import { SidebarGitPanel } from "./sidebar-git-panel.js"; import { UpdateModal } from "./update-modal.js"; import { readUiSettings, updateUiSettings, writeUiSettings, } from "../lib/ui-settings.js"; import { getAgentIdFromSessionKey, getSessionChannelForIcon, getSessionDisplayLabel, getSessionRowKey, } from "../lib/session-keys.js"; import { sanitizeAgentEmoji } from "../lib/agent-identity.js"; import { ThemeToggle } from "./theme-toggle.js"; const html = htm.bind(h); const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; const kBrowsePanelMinHeightPx = 120; const kBrowseBottomMinHeightPx = 120; const kBrowseResizerHeightPx = 6; const kDefaultBrowseBottomPanelHeightPx = 260; const kChatSidebarCollapsedAgentIdsKey = "chatSidebarCollapsedAgentIds"; const kChatChannelIconSrc = { telegram: "/assets/icons/telegram.svg", discord: "/assets/icons/discord.svg", slack: "/assets/icons/slack.svg", }; const readChatSidebarCollapsedAgentIds = () => { const raw = readUiSettings()[kChatSidebarCollapsedAgentIdsKey]; return Array.isArray(raw) ? raw : []; }; const kSidebarNavIconsById = { cron: AlarmLineIcon, usage: BarChartLineIcon, doctor: PulseLineIcon, watchdog: EyeLineIcon, models: Brain2LineIcon, envars: BracesLineIcon, webhooks: SignalTowerLineIcon, nodes: ComputerLineIcon, }; const readStoredBrowseBottomPanelHeight = () => { try { const settings = readUiSettings(); const fromSharedSettings = Number.parseInt( String(settings?.[kBrowseBottomPanelUiSettingKey] || ""), 10, ); if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) { return fromSharedSettings; } return kDefaultBrowseBottomPanelHeightPx; } catch { return kDefaultBrowseBottomPanelHeightPx; } }; const renderNavItem = ({ item, selectedNavId, onSelectNavItem }) => { const NavIcon = kSidebarNavIconsById[item.id] || null; return html` onSelectNavItem(item.id)} > ${NavIcon ? html`<${NavIcon} className="sidebar-nav-icon" />` : null} ${item.label} `; }; const getAgentIdentityEmoji = (agent) => sanitizeAgentEmoji(agent?.identity?.emoji); export const AppSidebar = ({ mobileSidebarOpen = false, authEnabled = false, menuRef = null, menuOpen = false, onToggleMenu = () => {}, onLogout = () => {}, sidebarTab = "menu", onSelectSidebarTab = () => {}, navSections = [], selectedNavId = "", onSelectNavItem = () => {}, selectedBrowsePath = "", onSelectBrowseFile = () => {}, onPreviewBrowseFile = () => {}, acHasUpdate = false, acVersion = "", acCurrentOpenclawVersion = "", acLatest = "", acLatestOpenclawVersion = "", acUpdateStrategy = null, acUpdating = false, onAcUpdate = () => {}, agents = [], selectedAgentId = "", onSelectAgent = () => {}, onAddAgent = () => {}, chatSessions = [], selectedChatSessionKey = "", onSelectChatSession = () => {}, }) => { const browseLayoutRef = useRef(null); const browseBottomPanelRef = useRef(null); const browseResizeStartRef = useRef({ startY: 0, startHeight: 0 }); const [browseBottomPanelHeightPx, setBrowseBottomPanelHeightPx] = useState( readStoredBrowseBottomPanelHeight, ); const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); const [collapsedChatAgentIds, setCollapsedChatAgentIds] = useState(() => readChatSidebarCollapsedAgentIds(), ); const chatSessionGroups = useMemo(() => { const rows = Array.isArray(chatSessions) ? chatSessions : []; const order = []; const byAgent = new Map(); for (const row of rows) { const aid = String( row.agentId || getAgentIdFromSessionKey(getSessionRowKey(row)) || "unknown", ); if (!byAgent.has(aid)) { byAgent.set(aid, { agentId: aid, agentLabel: String(row.agentLabel || "").trim() || aid, sessions: [], }); order.push(aid); } byAgent.get(aid).sessions.push(row); } const groups = order.map((aid) => byAgent.get(aid)); groups.sort((a, b) => { if (a.agentId === "main" && b.agentId !== "main") return -1; if (b.agentId === "main" && a.agentId !== "main") return 1; return a.agentLabel.localeCompare(b.agentLabel); }); return groups; }, [chatSessions]); const toggleChatAgentCollapsed = (agentId) => { const id = String(agentId || ""); setCollapsedChatAgentIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); const arr = Array.from(next); updateUiSettings((s) => ({ ...s, [kChatSidebarCollapsedAgentIdsKey]: arr, })); return arr; }); }; useEffect(() => { const settings = readUiSettings(); settings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx; writeUiSettings(settings); }, [browseBottomPanelHeightPx]); const getClampedBrowseBottomPanelHeight = (value) => { const layoutElement = browseLayoutRef.current; if (!layoutElement) return value; const layoutRect = layoutElement.getBoundingClientRect(); const maxHeight = Math.max( kBrowseBottomMinHeightPx, layoutRect.height - kBrowsePanelMinHeightPx - kBrowseResizerHeightPx, ); return Math.max( kBrowseBottomMinHeightPx, Math.min(maxHeight, value), ); }; const resizeBrowsePanelWithClientY = (clientY) => { const { startY, startHeight } = browseResizeStartRef.current; const proposedHeight = startHeight + (startY - clientY); setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(proposedHeight)); }; useEffect(() => { const layoutElement = browseLayoutRef.current; if (!layoutElement || typeof ResizeObserver === "undefined") return () => {}; const observer = new ResizeObserver(() => { const layoutRect = layoutElement.getBoundingClientRect(); if (layoutRect.height <= 0) return; setBrowseBottomPanelHeightPx((currentHeight) => getClampedBrowseBottomPanelHeight(currentHeight), ); }); observer.observe(layoutElement); return () => observer.disconnect(); }, []); useEffect(() => { if (!isResizingBrowsePanels) return () => {}; const handlePointerMove = (event) => resizeBrowsePanelWithClientY(event.clientY); const handlePointerUp = () => setIsResizingBrowsePanels(false); window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; }, [isResizingBrowsePanels]); const onBrowsePanelResizerPointerDown = (event) => { event.preventDefault(); const measuredHeight = browseBottomPanelRef.current?.getBoundingClientRect().height || browseBottomPanelHeightPx; browseResizeStartRef.current = { startY: event.clientY, startHeight: measuredHeight, }; setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(measuredHeight)); setIsResizingBrowsePanels(true); }; const setupSection = navSections.find((section) => section.label === "Setup") || null; const remainingSections = navSections.filter((section) => section.label !== "Setup"); return html`
${setupSection ? html` ` : null} ${remainingSections.map( (section) => html` `, )}
<${UpdateModal} visible=${updateModalOpen} onClose=${() => { if (acUpdating) return; setUpdateModalOpen(false); }} currentVersion=${acVersion} currentOpenclawVersion=${acCurrentOpenclawVersion} version=${acLatest} latestOpenclawVersion=${acLatestOpenclawVersion} updateStrategy=${acUpdateStrategy} onUpdate=${onAcUpdate} updating=${acUpdating} />
`; }; ================================================ FILE: lib/public/js/components/summary-stat-card.js ================================================ import { h } from "preact"; import htm from "htm"; const html = htm.bind(h); export const SummaryStatCard = ({ title = "", value = "—", toneClassName = "", valueClassName = "text-lg font-semibold text-body", monospace = false, } = {}) => html`

${title}

${value}
`; ================================================ FILE: lib/public/js/components/telegram-workspace/index.js ================================================ import { h } from "preact"; import { useState, useEffect } from "preact/hooks"; import htm from "htm"; import { showToast } from "../toast.js"; import * as api from "../../lib/telegram-api.js"; import { StepIndicator, VerifyBotStep, CreateGroupStep, AddBotStep, TopicsStep, SummaryStep, } from "./onboarding.js"; import { ManageTelegramWorkspace } from "./manage.js"; const html = htm.bind(h); const kSteps = [ { id: "verify-bot", label: "Verify Bot" }, { id: "create-group", label: "Create Group" }, { id: "add-bot", label: "Add Bot" }, { id: "topics", label: "Topics" }, { id: "summary", label: "Summary" }, ]; import { kTelegramWorkspaceStorageKey, kTelegramWorkspaceCacheKey, } from "../../lib/storage-keys.js"; const resolveStorageKey = (baseKey, accountId) => { const suffix = String(accountId || "").trim(); if (!suffix || suffix === "default") return baseKey; return `${baseKey}.${suffix}`; }; const loadTelegramWorkspaceState = (accountId) => { try { const raw = window.localStorage.getItem(resolveStorageKey(kTelegramWorkspaceStorageKey, accountId)); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } }; const saveTelegramWorkspaceState = (accountId, state) => { try { window.localStorage.setItem( resolveStorageKey(kTelegramWorkspaceStorageKey, accountId), JSON.stringify(state), ); } catch {} }; const removeTelegramWorkspaceState = (accountId) => { try { window.localStorage.removeItem(resolveStorageKey(kTelegramWorkspaceStorageKey, accountId)); } catch {} }; const loadTelegramWorkspaceCache = (accountId) => { try { const raw = window.localStorage.getItem(resolveStorageKey(kTelegramWorkspaceCacheKey, accountId)); if (!raw) return null; const parsed = JSON.parse(raw); const data = parsed?.data; if (!data || typeof data !== "object") return null; return data; } catch { return null; } }; const saveTelegramWorkspaceCache = (accountId, data) => { try { window.localStorage.setItem( resolveStorageKey(kTelegramWorkspaceCacheKey, accountId), JSON.stringify({ cachedAt: Date.now(), data }), ); } catch {} }; const removeTelegramWorkspaceCache = (accountId) => { try { window.localStorage.removeItem(resolveStorageKey(kTelegramWorkspaceCacheKey, accountId)); } catch {} }; const BackButton = ({ onBack }) => html` `; const MultiGroupView = ({ accountId, groups, concurrency, debugEnabled, onResetOnboarding, }) => { const [expandedGroupId, setExpandedGroupId] = useState( () => groups[0]?.groupId || "", ); const toggle = (gId) => setExpandedGroupId((current) => (current === gId ? "" : gId)); return html`
${groups.map( (g) => html`
${expandedGroupId === g.groupId && html`
<${ManageTelegramWorkspace} accountId=${accountId} groupId=${g.groupId} groupName=${g.groupName} initialTopics=${g.topics} configAgentMaxConcurrent=${concurrency?.agentMaxConcurrent} configSubagentMaxConcurrent=${concurrency?.subagentMaxConcurrent} debugEnabled=${debugEnabled} onResetOnboarding=${onResetOnboarding} />
`}
`, )}
`; }; export const TelegramWorkspace = ({ accountId = "default", onBack }) => { const initialState = loadTelegramWorkspaceState(accountId); const cachedWorkspace = loadTelegramWorkspaceCache(accountId); const [step, setStep] = useState(() => { const value = Number.parseInt(String(initialState.step ?? 0), 10); if (!Number.isFinite(value)) return 0; return Math.min(Math.max(value, 0), kSteps.length - 1); }); const [botInfo, setBotInfo] = useState(null); const [groupId, setGroupId] = useState(initialState.groupId || ""); const [groupInfo, setGroupInfo] = useState(initialState.groupInfo || null); const [verifyGroupError, setVerifyGroupError] = useState( initialState.verifyGroupError || null, ); const [allowUserId, setAllowUserId] = useState( initialState.allowUserId || "", ); const [topics, setTopics] = useState(initialState.topics || {}); const [workspaceConfig, setWorkspaceConfig] = useState(() => ({ ready: !!cachedWorkspace, configured: !!cachedWorkspace?.configured, groups: cachedWorkspace?.groups || [], groupId: cachedWorkspace?.groupId || "", groupName: cachedWorkspace?.groupName || "", topics: cachedWorkspace?.topics || {}, debugEnabled: !!cachedWorkspace?.debugEnabled, concurrency: cachedWorkspace?.concurrency || { agentMaxConcurrent: null, subagentMaxConcurrent: null, }, })); const goNext = () => setStep((s) => Math.min(kSteps.length - 1, s + 1)); const goBack = () => setStep((s) => Math.max(0, s - 1)); const resetOnboarding = async () => { try { const data = await api.resetWorkspace({ accountId }); if (!data.ok) throw new Error(data.error || "Failed to reset onboarding"); removeTelegramWorkspaceState(accountId); removeTelegramWorkspaceCache(accountId); setStep(0); setBotInfo(null); setGroupId(""); setGroupInfo(null); setVerifyGroupError(null); setAllowUserId(""); setTopics({}); setWorkspaceConfig({ ready: true, configured: false, groups: [], groupId: "", groupName: "", topics: {}, debugEnabled: !!workspaceConfig?.debugEnabled, concurrency: { agentMaxConcurrent: null, subagentMaxConcurrent: null }, }); showToast("Telegram onboarding reset", "success"); } catch (e) { showToast(e.message || "Failed to reset onboarding", "error"); } }; const handleDone = () => { removeTelegramWorkspaceState(accountId); const doneGroupName = groupInfo?.chat?.title || groupId; saveTelegramWorkspaceCache(accountId, { ready: true, configured: true, groups: [{ groupId, groupName: doneGroupName, topics: topics || {} }], groupId, groupName: doneGroupName, topics: topics || {}, debugEnabled: !!workspaceConfig?.debugEnabled, concurrency: workspaceConfig?.concurrency || { agentMaxConcurrent: null, subagentMaxConcurrent: null, }, }); window.location.reload(); }; useEffect(() => { saveTelegramWorkspaceState(accountId, { step, groupId, groupInfo, verifyGroupError, allowUserId, topics, }); }, [ accountId, step, groupId, groupInfo, verifyGroupError, allowUserId, topics, ]); useEffect(() => { let active = true; const bootstrapWorkspace = async () => { try { const data = await api.workspace({ accountId }); if (!active || !data?.ok) return; const groups = Array.isArray(data.groups) ? data.groups : []; if (!data.configured || groups.length === 0) { const nextConfig = { ready: true, configured: false, groups: [], groupId: "", groupName: "", topics: {}, debugEnabled: !!data?.debugEnabled, concurrency: { agentMaxConcurrent: null, subagentMaxConcurrent: null, }, }; setWorkspaceConfig(nextConfig); saveTelegramWorkspaceCache(accountId, nextConfig); return; } const first = groups[0]; const nextConfig = { ready: true, configured: true, groups, groupId: first.groupId, groupName: first.groupName || first.groupId, topics: first.topics || {}, debugEnabled: !!data.debugEnabled, concurrency: data.concurrency || { agentMaxConcurrent: null, subagentMaxConcurrent: null, }, }; setWorkspaceConfig(nextConfig); saveTelegramWorkspaceCache(accountId, nextConfig); setGroupId(first.groupId); setTopics(first.topics || {}); setGroupInfo({ chat: { id: first.groupId, title: first.groupName || first.groupId, isForum: true, }, bot: { status: "administrator", isAdmin: true, canManageTopics: true, }, }); setVerifyGroupError(null); setAllowUserId(""); setStep((currentStep) => (currentStep < 3 ? 3 : currentStep)); } catch {} }; bootstrapWorkspace(); return () => { active = false; }; }, [accountId]); return html`
<${BackButton} onBack=${onBack} />
${!workspaceConfig.ready ? html`

Loading workspace...

` : workspaceConfig.configured ? html`

Manage Telegram Workspace

${(workspaceConfig.groups || []).length <= 1 ? html` <${ManageTelegramWorkspace} accountId=${accountId} groupId=${workspaceConfig.groupId} groupName=${workspaceConfig.groupName} initialTopics=${workspaceConfig.topics} configAgentMaxConcurrent=${workspaceConfig.concurrency ?.agentMaxConcurrent} configSubagentMaxConcurrent=${workspaceConfig.concurrency ?.subagentMaxConcurrent} debugEnabled=${workspaceConfig.debugEnabled} onResetOnboarding=${resetOnboarding} /> ` : html` <${MultiGroupView} accountId=${accountId} groups=${workspaceConfig.groups} concurrency=${workspaceConfig.concurrency} debugEnabled=${workspaceConfig.debugEnabled} onResetOnboarding=${resetOnboarding} /> `} ` : html`

Set Up Telegram Workspace

Step ${step + 1} of ${kSteps.length}
<${StepIndicator} currentStep=${step} steps=${kSteps} /> ${step === 0 && html` <${VerifyBotStep} accountId=${accountId} botInfo=${botInfo} setBotInfo=${setBotInfo} onNext=${goNext} /> `} ${step === 1 && html` <${CreateGroupStep} onNext=${goNext} onBack=${goBack} /> `} ${step === 2 && html` <${AddBotStep} accountId=${accountId} groupId=${groupId} setGroupId=${setGroupId} groupInfo=${groupInfo} setGroupInfo=${setGroupInfo} userId=${allowUserId} setUserId=${setAllowUserId} verifyGroupError=${verifyGroupError} setVerifyGroupError=${setVerifyGroupError} onNext=${goNext} onBack=${goBack} /> `} ${step === 3 && html` <${TopicsStep} accountId=${accountId} groupId=${groupId} topics=${topics} setTopics=${setTopics} onNext=${goNext} onBack=${goBack} /> `} ${step === 4 && html` <${SummaryStep} groupId=${groupId} groupInfo=${groupInfo} topics=${topics} onBack=${goBack} onDone=${handleDone} /> `} `}
`; }; ================================================ FILE: lib/public/js/components/telegram-workspace/manage.js ================================================ import { h } from "preact"; import { useState, useEffect } from "preact/hooks"; import htm from "htm"; import { showToast } from "../toast.js"; import { ActionButton } from "../action-button.js"; import { ConfirmDialog } from "../confirm-dialog.js"; import * as api from "../../lib/telegram-api.js"; import { fetchAgents } from "../../lib/api.js"; const html = htm.bind(h); const AgentSelect = ({ value, agents, onChange, className = "" }) => html` `; export const ManageTelegramWorkspace = ({ accountId, groupId, groupName, initialTopics, configAgentMaxConcurrent, configSubagentMaxConcurrent, debugEnabled, onResetOnboarding, }) => { const [topics, setTopics] = useState(initialTopics || {}); const [newTopicName, setNewTopicName] = useState(""); const [newTopicInstructions, setNewTopicInstructions] = useState(""); const [newTopicAgentId, setNewTopicAgentId] = useState(""); const [showCreateTopic, setShowCreateTopic] = useState(false); const [creating, setCreating] = useState(false); const [deleting, setDeleting] = useState(null); const [editingTopicId, setEditingTopicId] = useState(""); const [editingTopicName, setEditingTopicName] = useState(""); const [editingTopicInstructions, setEditingTopicInstructions] = useState(""); const [editingTopicAgentId, setEditingTopicAgentId] = useState(""); const [renamingTopicId, setRenamingTopicId] = useState(""); const [error, setError] = useState(null); const [deleteTopicConfirm, setDeleteTopicConfirm] = useState(null); const [agents, setAgents] = useState([]); const loadTopics = async () => { const data = await api.listTopics(groupId, { accountId }); if (data.ok) setTopics(data.topics || {}); }; useEffect(() => { loadTopics(); }, [groupId]); useEffect(() => { if (initialTopics && Object.keys(initialTopics).length > 0) { setTopics(initialTopics); } }, [initialTopics]); useEffect(() => { fetchAgents() .then((data) => setAgents(Array.isArray(data?.agents) ? data.agents : [])) .catch(() => {}); }, []); const createSingle = async () => { const name = newTopicName.trim(); const systemInstructions = newTopicInstructions.trim(); const agentId = newTopicAgentId.trim(); if (!name) return; setCreating(true); setError(null); try { const data = await api.createTopicsBulk(groupId, [ { name, ...(systemInstructions ? { systemInstructions } : {}), ...(agentId ? { agentId } : {}), }, ], { accountId }); if (!data.ok) throw new Error(data.results?.[0]?.error || "Failed to create topic"); const failed = data.results.filter((r) => !r.ok); if (failed.length > 0) throw new Error(failed[0].error); setNewTopicName(""); setNewTopicInstructions(""); setNewTopicAgentId(""); setShowCreateTopic(false); await loadTopics(); showToast(`Created topic: ${name}`, "success"); } catch (e) { setError(e.message); } setCreating(false); }; const handleDelete = async (topicId, topicName) => { setDeleting(topicId); try { const data = await api.deleteTopic(groupId, topicId, { accountId }); if (!data.ok) throw new Error(data.error); await loadTopics(); if (data.removedFromRegistryOnly) { showToast(`Removed stale topic from registry: ${topicName}`, "success"); } else { showToast(`Deleted topic: ${topicName}`, "success"); } } catch (e) { showToast(`Failed to delete: ${e.message}`, "error"); } setDeleting(null); }; const startRename = (topicId, topicName, topicInstructions = "", topicAgentId = "") => { setEditingTopicId(String(topicId)); setEditingTopicName(String(topicName || "")); setEditingTopicInstructions(String(topicInstructions || "")); setEditingTopicAgentId(String(topicAgentId || "")); }; const cancelRename = () => { setEditingTopicId(""); setEditingTopicName(""); setEditingTopicInstructions(""); setEditingTopicAgentId(""); }; const saveRename = async (topicId) => { const nextName = editingTopicName.trim(); const nextSystemInstructions = editingTopicInstructions.trim(); const nextAgentId = editingTopicAgentId.trim(); if (!nextName) { setError("Topic name is required"); return; } setRenamingTopicId(String(topicId)); setError(null); try { const data = await api.updateTopic(groupId, topicId, { name: nextName, systemInstructions: nextSystemInstructions, agentId: nextAgentId, }, { accountId }); if (!data.ok) throw new Error(data.error || "Failed to update topic"); await loadTopics(); showToast(`Updated topic: ${nextName}`, "success"); cancelRename(); } catch (e) { setError(e.message); } setRenamingTopicId(""); }; const topicEntries = Object.entries(topics || {}); const topicCount = topicEntries.length; const computedMaxConcurrent = Math.max(topicCount * 3, 8); const computedSubagentMaxConcurrent = Math.max(computedMaxConcurrent - 2, 4); const maxConcurrent = Number.isFinite(configAgentMaxConcurrent) ? configAgentMaxConcurrent : computedMaxConcurrent; const subagentMaxConcurrent = Number.isFinite(configSubagentMaxConcurrent) ? configSubagentMaxConcurrent : computedSubagentMaxConcurrent; return html`
${debugEnabled && html`
`}

${groupName || groupId}

${groupId}

Existing Topics

${topicEntries.length > 0 ? html`
${agents.length > 0 && html` `} ${topicEntries.map( ([id, topic]) => html` ${editingTopicId === String(id) ? html`
Topic Thread ID Agent
0 ? 4 : 3}>
setEditingTopicName(e.target.value)} onKeyDown=${(e) => { if (e.key === "Enter") saveRename(id); if (e.key === "Escape") cancelRename(); }} class="w-full bg-field border border-border rounded-lg px-2 py-1.5 text-xs text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" />