diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..e8a0529a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: chrysb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41195086..72bc5333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,26 +2,63 @@ name: CI on: push: - branches: [main] + branches: [main, pr-4-macos, feature/MacOS-post-install] pull_request: branches: [main] jobs: test: - runs-on: ubuntu-latest + name: Test (Node ${{ matrix.node-version }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: + os: [ubuntu-latest, macos-latest] node-version: [22] steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: npm + cache: "npm" + + - name: Verify arch (macOS) + if: runner.os == 'macOS' + run: | + echo "arch: $(uname -m) | node: $(node --version)" + [ "$(uname -m)" = "x86_64" ] && echo "::warning::Rosetta x64 shell β€” esbuild arch mismatch risk" || true + + - name: Set sudo-free npm prefix (macOS) + if: runner.os == 'macOS' + run: | + npm config set prefix ~/.local + mkdir -p ~/.local/bin + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: npm install - - run: npm ci + - name: Build UI + run: npm run build:ui - - run: npm test + - name: Full test suite + run: npm test + + - name: Watchdog tests + run: npm run test:watchdog + + - name: Coverage + run: npm run test:coverage + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index de40a095..362f6c45 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ node_modules/ .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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9ed784be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,235 @@ +## 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 both deployment templates on `main` to that release: update `~/Projects/openclaw-railway-template` and `~/Projects/openclaw-render-template` so `@chrysb/alphaclaw` and `openclaw` match AlphaClaw’s `package.json` for the release, run `npm install` in each repo, commit `package.json` and `package-lock.json`, and push. Skipping Render leaves that template on `latest` and causes drift from Railway. +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 ` - ${menuOpen && html` - - `} - - `} - - ${kNavSections.map( - (section) => html` - - - `, - )} - - + <${AppSidebar} + mobileSidebarOpen=${shellState.mobileSidebarOpen} + authEnabled=${controllerState.authEnabled} + menuRef=${shellRefs.menuRef} + menuOpen=${shellState.menuOpen} + onToggleMenu=${shellActions.onToggleMenu} + onLogout=${handleSidebarLogout} + sidebarTab=${browseState.sidebarTab} + onSelectSidebarTab=${browseActions.handleSelectSidebarTab} + navSections=${browseConstants.kNavSections} + selectedNavId=${browseState.selectedNavId} + onSelectNavItem=${browseActions.handleSelectNavItem} + selectedBrowsePath=${browseState.selectedBrowsePath} + onSelectBrowseFile=${browseActions.navigateToBrowseFile} + onPreviewBrowseFile=${browseActions.handleBrowsePreviewFile} + acHasUpdate=${controllerState.acHasUpdate} + acVersion=${controllerState.acVersion} + acCurrentOpenclawVersion=${controllerState.acCurrentOpenclawVersion} + acLatest=${controllerState.acLatest} + acLatestOpenclawVersion=${controllerState.acLatestOpenclawVersion} + acUpdateStrategy=${controllerState.acUpdateStrategy} + acUpdating=${controllerState.acUpdating} + onAcUpdate=${controllerActions.handleAcUpdate} + agents=${agentsState.agents} + selectedAgentId=${selectedAgentId} + onSelectAgent=${(agentId) => setLocation(`/agents/${encodeURIComponent(agentId)}`)} + onAddAgent=${() => { + if (isAgentsRoute) { + window.dispatchEvent(new Event("alphaclaw:create-agent")); + return; + } + window[kPendingCreateAgentWindowFlag] = true; + setLocation("/agents"); + }} + chatSessions=${chatSessions} + selectedChatSessionKey=${selectedChatSessionKey} + onSelectChatSession=${(sessionKey) => { + setSelectedChatSessionKey(sessionKey); + if (!isChatRoute) setLocation("/chat"); + }} + /> +
setMobileSidebarOpen(false)} + class=${`mobile-sidebar-overlay ${shellState.mobileSidebarOpen ? "active" : ""}`} + onclick=${shellActions.closeMobileSidebar} /> -
-
+
+
-
- <${Switch}> - <${Route} path="/telegram"> -
- <${TelegramWorkspace} onBack=${exitSubScreen} /> + ${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"); + }} + />
- - <${Route} path="/general"> -
- <${GeneralTab} - statusData=${sharedStatus} - watchdogData=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} - onNavigate=${navigateToSubScreen} - isActive=${location === "/general"} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} + ` + : 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} />
- - <${Route} path="/providers"> -
- <${Providers} onRestartRequired=${setRestartRequired} /> + ` + : null} + ${isChatRoute + ? html` +
+ <${ChatRoute} + sessions=${chatSessions} + selectedSessionKey=${selectedChatSessionKey} + />
- - <${Route} path="/watchdog"> -
- <${WatchdogTab} - gatewayStatus=${sharedStatus?.gateway || null} - openclawVersion=${sharedStatus?.openclawVersion || null} - watchdogStatus=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} + ` + : null} + ${isCronRoute + ? html` +
+ <${CronRoute} + jobId=${selectedCronJobId} + onSetLocation=${setLocation} />
- - <${Route} path="/envars"> -
- <${Envars} onRestartRequired=${setRestartRequired} /> + ` + : null} + ${isEnvarsRoute + ? html` +
+ <${EnvarsRoute} + onRestartRequired=${controllerActions.setRestartRequired} + />
- - <${Route} path="/webhooks/:hookName"> - ${(params) => renderWebhooks(decodeURIComponent(params.hookName || ""))} - - <${Route} path="/webhooks"> - ${() => renderWebhooks("")} - - <${Route}> - <${RouteRedirect} to="/general" /> - - -
+ ` + : 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 bottom-10 right-4 z-50 space-y-2 pointer-events-none" + className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none" />
- ${acVersion - ? html`v${acVersion}` + ${footerVersion + ? html`${footerVersion}` : null}
@@ -817,11 +512,19 @@ const App = () => { `; }; -render( - html` - <${Router} hook=${useHashLocation}> - <${App} /> - - `, - document.getElementById("app"), -); +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, + ); +} diff --git a/lib/public/js/components/action-button.js b/lib/public/js/components/action-button.js index 1200f64e..7a79d3a7 100644 --- a/lib/public/js/components/action-button.js +++ b/lib/public/js/components/action-button.js @@ -1,5 +1,5 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; import { LoadingSpinner } from "./loading-spinner.js"; const html = htm.bind(h); @@ -13,15 +13,20 @@ const kStaticToneClassByTone = { }; 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-gray-500 hover:text-gray-300 hover:border-gray-500" - : "border border-border text-gray-500"; + ? "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-yellow-400 bg-yellow-500/10 hover:border-yellow-400/60 hover:text-yellow-300 hover:bg-yellow-500/15" - : "border border-yellow-500/35 text-yellow-400 bg-yellow-500/10"; + ? "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; }; @@ -31,6 +36,11 @@ const kSizeClassBySize = { 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, @@ -43,11 +53,18 @@ export const ActionButton = ({ 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 = kSizeClassBySize[size] || kSizeClassBySize.sm; + const sizeClass = iconOnly + ? kIconOnlySizeClassBySize[size] || kIconOnlySizeClassBySize.sm + : kSizeClassBySize[size] || kSizeClassBySize.sm; const loadingClass = loading ? `cursor-not-allowed ${ tone === "warning" @@ -55,24 +72,43 @@ export const ActionButton = ({ : "opacity-80" }` : ""; - const spinnerSizeClass = size === "md" || size === "lg" ? "h-4 w-4" : "h-3 w-3"; + const spinnerSizeClass = + size === "md" || size === "lg" ? "h-4 w-4" : "h-3 w-3"; const isInlineLoading = loadingMode === "inline"; - const currentLabel = loading && !isInlineLoading ? loadingLabel : idleLabel; + 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` `; }; diff --git a/lib/public/js/components/add-channel-menu.js b/lib/public/js/components/add-channel-menu.js new file mode 100644 index 00000000..bd55816b --- /dev/null +++ b/lib/public/js/components/add-channel-menu.js @@ -0,0 +1,59 @@ +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} + + `; + })} + +`; + diff --git a/lib/public/js/components/agent-send-modal.js b/lib/public/js/components/agent-send-modal.js new file mode 100644 index 00000000..3032145a --- /dev/null +++ b/lib/public/js/components/agent-send-modal.js @@ -0,0 +1,128 @@ +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} + /> +
+ + `; +}; diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js b/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js new file mode 100644 index 00000000..034118aa --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js @@ -0,0 +1,203 @@ +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} + /> + +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/helpers.js b/lib/public/js/components/agents-tab/agent-bindings-section/helpers.js new file mode 100644 index 00000000..167aaeb0 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-bindings-section/helpers.js @@ -0,0 +1,74 @@ +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; +}; diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/index.js b/lib/public/js/components/agents-tab/agent-bindings-section/index.js new file mode 100644 index 00000000..0f338b51 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-bindings-section/index.js @@ -0,0 +1,198 @@ +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); + }} + /> +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js b/lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js new file mode 100644 index 00000000..acb00420 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js @@ -0,0 +1,256 @@ +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, + }; +}; diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js new file mode 100644 index 00000000..a65a1ff1 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js @@ -0,0 +1,205 @@ +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, + }; +}; diff --git a/lib/public/js/components/agents-tab/agent-detail-panel.js b/lib/public/js/components/agents-tab/agent-detail-panel.js new file mode 100644 index 00000000..4463b35b --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-detail-panel.js @@ -0,0 +1,167 @@ +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} + /> + `} +
+
+
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-identity-section.js b/lib/public/js/components/agents-tab/agent-identity-section.js new file mode 100644 index 00000000..c8486dc7 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-identity-section.js @@ -0,0 +1,175 @@ +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`β€”`} + +
+
+ `} +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/index.js b/lib/public/js/components/agents-tab/agent-overview/index.js new file mode 100644 index 00000000..2cc164ca --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/index.js @@ -0,0 +1,62 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/manage-card.js b/lib/public/js/components/agents-tab/agent-overview/manage-card.js new file mode 100644 index 00000000..4fef7f18 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/manage-card.js @@ -0,0 +1,44 @@ +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} +
+
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/model-card.js b/lib/public/js/components/agents-tab/agent-overview/model-card.js new file mode 100644 index 00000000..ca5bd67f --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/model-card.js @@ -0,0 +1,158 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/tools-card.js b/lib/public/js/components/agents-tab/agent-overview/tools-card.js new file mode 100644 index 00000000..ebee476a --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/tools-card.js @@ -0,0 +1,54 @@ +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 + + + + + +
+
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/use-model-card.js b/lib/public/js/components/agents-tab/agent-overview/use-model-card.js new file mode 100644 index 00000000..e248aeff --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/use-model-card.js @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; +import { useModels } from "../../models-tab/use-models.js"; +import { + buildProviderHasAuth, + 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) || { + key: modelKey, + label: 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 ? { key: effectiveModel, label: 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, + }; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/use-workspace-card.js b/lib/public/js/components/agents-tab/agent-overview/use-workspace-card.js new file mode 100644 index 00000000..b848d2bf --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/use-workspace-card.js @@ -0,0 +1,45 @@ +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, + }; +}; diff --git a/lib/public/js/components/agents-tab/agent-overview/workspace-card.js b/lib/public/js/components/agents-tab/agent-overview/workspace-card.js new file mode 100644 index 00000000..8ebd842f --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-overview/workspace-card.js @@ -0,0 +1,47 @@ +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

`} +
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-pairing-section.js b/lib/public/js/components/agents-tab/agent-pairing-section.js new file mode 100644 index 00000000..af1c1004 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-pairing-section.js @@ -0,0 +1,313 @@ +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: hasUnpaired && ownedAccounts.length > 0, + cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`, + }, + ); + + const pending = pairingsPoll.data || []; + + 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(); + 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 { + await approvePairing(id, channel, accountId); + refreshAfterPairingAction(); + } catch (err) { + showToast(err.message || "Could not approve pairing", "error"); + } + }; + + const handleReject = async (id, channel, accountId = "") => { + try { + await rejectPairing(id, channel, accountId); + refreshAfterPairingAction(); + } catch (err) { + showToast(err.message || "Could not reject pairing", "error"); + } + }; + + if (loadingBindings) { + return html` +
+

Pairing

+

Loading pairing status...

+
+ `; + } + + if (!hasUnpaired) return null; + + return html` + <${Pairings} + pending=${pending} + channels=${ownedChannelsStatus} + visible=${hasUnpaired} + statusRefreshing=${pairingStatusRefreshing} + onApprove=${handleApprove} + onReject=${handleReject} + /> + `; +}; diff --git a/lib/public/js/components/agents-tab/agent-tools/index.js b/lib/public/js/components/agents-tab/agent-tools/index.js new file mode 100644 index 00000000..3100d833 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-tools/index.js @@ -0,0 +1,105 @@ +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} + /> +
+ `, + )} +
+
+ `; +}; diff --git a/lib/public/js/components/agents-tab/agent-tools/tool-catalog.js b/lib/public/js/components/agents-tab/agent-tools/tool-catalog.js new file mode 100644 index 00000000..b72d9676 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-tools/tool-catalog.js @@ -0,0 +1,289 @@ +/** + * 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; +}; diff --git a/lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js b/lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js new file mode 100644 index 00000000..fa7abe16 --- /dev/null +++ b/lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js @@ -0,0 +1,128 @@ +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, + }; +}; diff --git a/lib/public/js/components/agents-tab/create-agent-modal.js b/lib/public/js/components/agents-tab/create-agent-modal.js new file mode 100644 index 00000000..e8344a54 --- /dev/null +++ b/lib/public/js/components/agents-tab/create-agent-modal.js @@ -0,0 +1,189 @@ +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..." + /> +
+ + `; +}; diff --git a/lib/public/js/components/agents-tab/create-channel-modal.js b/lib/public/js/components/agents-tab/create-channel-modal.js new file mode 100644 index 00000000..191c251d --- /dev/null +++ b/lib/public/js/components/agents-tab/create-channel-modal.js @@ -0,0 +1,701 @@ +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. +
  3. + Open ${" "} + Basic Information + ${" "} and create an ${" "} + App-Level Token + ${" "} with + connections:write. +
  4. +
  5. + Open ${" "} + OAuth & Permissions + ${" "} and use ${" "} + Install to Workspace + ${" "} or ${" "} + Reinstall to Workspace + ${" "} so Slack issues a bot token. +
  6. +
  7. + In ${" "} + OAuth & Permissions + ${" "} copy the ${" "} + Bot User OAuth Token + ${" "} ( + xoxb-... + ). +
  8. +
  9. + Paste the generated ${" "} + xoxb-... + ${" "} and ${" "} + xapp-... + ${" "} tokens here. +
  10. +
+
+
+
+ + + 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. +
  3. + In ${" "} + App Home, enable + + Allow users to send Slash commands and messages from + the messages tab . +
  4. +
  5. + In ${" "} + Event Subscriptions, + toggle on + Subscribe to bot events + ${" "} and add + message.im. +
  6. +
  7. + In ${" "} + OAuth & Permissions, + add the bot scopes: + + ${kSlackBotScopes.join(", ")} + +
  8. +
  9. + In ${" "} + Basic Information, + create an App Token (xapp-...) with + connections:write. +
  10. +
  11. + Back in ${" "} + OAuth & Permissions, + install or reinstall the app, then copy the ${" "} + Bot User OAuth Token + ${" "} ( + xoxb-... + ). +
  12. +
+ + 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} + /> +
+ + `; +}; diff --git a/lib/public/js/components/agents-tab/delete-agent-dialog.js b/lib/public/js/components/agents-tab/delete-agent-dialog.js new file mode 100644 index 00000000..de3e36fc --- /dev/null +++ b/lib/public/js/components/agents-tab/delete-agent-dialog.js @@ -0,0 +1,50 @@ +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, + })} + /> + `; +}; diff --git a/lib/public/js/components/agents-tab/edit-agent-modal.js b/lib/public/js/components/agents-tab/edit-agent-modal.js new file mode 100644 index 00000000..044d9c8f --- /dev/null +++ b/lib/public/js/components/agents-tab/edit-agent-modal.js @@ -0,0 +1,109 @@ +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..." + /> +
+ + `; +}; diff --git a/lib/public/js/components/agents-tab/index.js b/lib/public/js/components/agents-tab/index.js new file mode 100644 index 00000000..8fa4e3be --- /dev/null +++ b/lib/public/js/components/agents-tab/index.js @@ -0,0 +1,152 @@ +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} + /> + `; +}; diff --git a/lib/public/js/components/agents-tab/use-agents.js b/lib/public/js/components/agents-tab/use-agents.js new file mode 100644 index 00000000..79f5c6ae --- /dev/null +++ b/lib/public/js/components/agents-tab/use-agents.js @@ -0,0 +1,89 @@ +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, + }, + }; +}; diff --git a/lib/public/js/components/badge.js b/lib/public/js/components/badge.js index fecc237b..431237da 100644 --- a/lib/public/js/components/badge.js +++ b/lib/public/js/components/badge.js @@ -1,13 +1,17 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; const html = htm.bind(h); const kToneClasses = { - success: "bg-green-500/10 text-green-500", - warning: "bg-yellow-500/10 text-yellow-500", - danger: "bg-red-500/10 text-red-400", - neutral: "bg-gray-500/10 text-gray-400", + 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` diff --git a/lib/public/js/components/channel-account-status-badge.js b/lib/public/js/components/channel-account-status-badge.js new file mode 100644 index 00000000..aa16dea9 --- /dev/null +++ b/lib/public/js/components/channel-account-status-badge.js @@ -0,0 +1,35 @@ +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)})`} + + `; +}; diff --git a/lib/public/js/components/channel-login-modal.js b/lib/public/js/components/channel-login-modal.js new file mode 100644 index 00000000..e59a074e --- /dev/null +++ b/lib/public/js/components/channel-login-modal.js @@ -0,0 +1,82 @@ +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} + /> + `} +
+`; diff --git a/lib/public/js/components/file-viewer/frontmatter-panel.js b/lib/public/js/components/file-viewer/frontmatter-panel.js new file mode 100644 index 00000000..c7fa2726 --- /dev/null +++ b/lib/public/js/components/file-viewer/frontmatter-panel.js @@ -0,0 +1,56 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js new file mode 100644 index 00000000..10391b71 --- /dev/null +++ b/lib/public/js/components/file-viewer/index.js @@ -0,0 +1,204 @@ +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); + }} + /> +
+ `; +}; diff --git a/lib/public/js/components/file-viewer/markdown-split-view.js b/lib/public/js/components/file-viewer/markdown-split-view.js new file mode 100644 index 00000000..1bb1ac0d --- /dev/null +++ b/lib/public/js/components/file-viewer/markdown-split-view.js @@ -0,0 +1,54 @@ +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} + /> +`; diff --git a/lib/public/js/components/file-viewer/media-preview.js b/lib/public/js/components/file-viewer/media-preview.js new file mode 100644 index 00000000..ef4a6663 --- /dev/null +++ b/lib/public/js/components/file-viewer/media-preview.js @@ -0,0 +1,44 @@ +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; +}; diff --git a/lib/public/js/components/file-viewer/scroll-sync.js b/lib/public/js/components/file-viewer/scroll-sync.js new file mode 100644 index 00000000..44bb4401 --- /dev/null +++ b/lib/public/js/components/file-viewer/scroll-sync.js @@ -0,0 +1,95 @@ +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, + }; +}; diff --git a/lib/public/js/components/file-viewer/sqlite-viewer.js b/lib/public/js/components/file-viewer/sqlite-viewer.js new file mode 100644 index 00000000..a539a6c7 --- /dev/null +++ b/lib/public/js/components/file-viewer/sqlite-viewer.js @@ -0,0 +1,167 @@ +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. +
+ `} +
+ `; +}; diff --git a/lib/public/js/components/file-viewer/status-banners.js b/lib/public/js/components/file-viewer/status-banners.js new file mode 100644 index 00000000..b932168a --- /dev/null +++ b/lib/public/js/components/file-viewer/status-banners.js @@ -0,0 +1,64 @@ +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} +`; diff --git a/lib/public/js/components/file-viewer/storage.js b/lib/public/js/components/file-viewer/storage.js new file mode 100644 index 00000000..17b8cc6d --- /dev/null +++ b/lib/public/js/components/file-viewer/storage.js @@ -0,0 +1,55 @@ +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 {} +}; diff --git a/lib/public/js/components/file-viewer/toolbar.js b/lib/public/js/components/file-viewer/toolbar.js new file mode 100644 index 00000000..6ae995f8 --- /dev/null +++ b/lib/public/js/components/file-viewer/toolbar.js @@ -0,0 +1,144 @@ +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} +
+`; diff --git a/lib/public/js/components/file-viewer/use-editor-line-number-sync.js b/lib/public/js/components/file-viewer/use-editor-line-number-sync.js new file mode 100644 index 00000000..ec1d4132 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-editor-line-number-sync.js @@ -0,0 +1,36 @@ +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, + }; +}; diff --git a/lib/public/js/components/file-viewer/use-editor-selection-restore.js b/lib/public/js/components/file-viewer/use-editor-selection-restore.js new file mode 100644 index 00000000..1ab033d5 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-editor-selection-restore.js @@ -0,0 +1,206 @@ +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, + ]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-diff.js b/lib/public/js/components/file-viewer/use-file-diff.js new file mode 100644 index 00000000..bc516d52 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-diff.js @@ -0,0 +1,60 @@ +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, + }; +}; diff --git a/lib/public/js/components/file-viewer/use-file-loader.js b/lib/public/js/components/file-viewer/use-file-loader.js new file mode 100644 index 00000000..9ade02e8 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-loader.js @@ -0,0 +1,312 @@ +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, + }; +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js b/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js new file mode 100644 index 00000000..2482b0b9 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js @@ -0,0 +1,32 @@ +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]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js b/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js new file mode 100644 index 00000000..d660f7e1 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js @@ -0,0 +1,25 @@ +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]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer.js b/lib/public/js/components/file-viewer/use-file-viewer.js new file mode 100644 index 00000000..0840c9ac --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer.js @@ -0,0 +1,511 @@ +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, + }, + }; +}; diff --git a/lib/public/js/components/file-viewer/utils.js b/lib/public/js/components/file-viewer/utils.js new file mode 100644 index 00000000..b37a4a53 --- /dev/null +++ b/lib/public/js/components/file-viewer/utils.js @@ -0,0 +1,26 @@ +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); diff --git a/lib/public/js/components/gateway.js b/lib/public/js/components/gateway.js index 97f77e09..47ae0a36 100644 --- a/lib/public/js/components/gateway.js +++ b/lib/public/js/components/gateway.js @@ -1,13 +1,7 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; -import { - fetchOpenclawVersion, - updateOpenclaw, -} from "../lib/api.js"; +import { h } from "preact"; +import { useEffect, useMemo, useState } from "preact/hooks"; +import htm from "htm"; import { UpdateActionButton } from "./update-action-button.js"; -import { ConfirmDialog } from "./confirm-dialog.js"; -import { showToast } from "./toast.js"; const html = htm.bind(h); const formatDuration = (ms) => { @@ -24,245 +18,40 @@ const formatDuration = (ms) => { return `${seconds}s`; }; -function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { - const [checking, setChecking] = useState(false); - const [version, setVersion] = useState(currentVersion || null); - const [latestVersion, setLatestVersion] = useState(null); - const [hasUpdate, setHasUpdate] = useState(false); - const [error, setError] = useState(""); - const [hasViewedChangelog, setHasViewedChangelog] = useState(false); - const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] = useState(false); - const simulateUpdate = (() => { - try { - const params = new URLSearchParams(window.location.search); - return params.get("simulateUpdate") === "1"; - } catch { - return false; - } - })(); - const simulatedVersion = (() => { - if (!simulateUpdate) return null; - try { - const params = new URLSearchParams(window.location.search); - return params.get("simulateVersion") || "v0.0.0-preview"; - } catch { - return "v0.0.0-preview"; - } - })(); - const effectiveHasUpdate = simulateUpdate || hasUpdate; - const effectiveLatestVersion = simulatedVersion || latestVersion; - const changelogUrl = "https://github.com/openclaw/openclaw/tags"; - const showMobileUpdateRow = effectiveHasUpdate && effectiveLatestVersion; - - useEffect(() => { - setVersion(currentVersion || null); - }, [currentVersion]); - - useEffect(() => { - let active = true; - const load = async () => { - try { - const data = await fetchVersion(false); - if (!active) return; - setVersion(data.currentVersion || currentVersion || null); - setLatestVersion(data.latestVersion || null); - setHasUpdate(!!data.hasUpdate); - setError(data.ok ? "" : data.error || ""); - } catch (err) { - if (!active) return; - setError(err.message || "Could not check updates"); - } - }; - load(); - return () => { - active = false; - }; - }, []); - - useEffect(() => { - if (!effectiveHasUpdate || !effectiveLatestVersion) { - setHasViewedChangelog(false); - return; - } - setHasViewedChangelog(false); - }, [effectiveHasUpdate, effectiveLatestVersion]); - - const runAction = async () => { - if (checking) return; - setChecking(true); - setError(""); - try { - const data = effectiveHasUpdate ? await applyUpdate() : await fetchVersion(true); - setVersion(data.currentVersion || version); - setLatestVersion(data.latestVersion || null); - setHasUpdate(!!data.hasUpdate); - setError(data.ok ? "" : data.error || ""); - if (effectiveHasUpdate) { - if (!data.ok) { - showToast(data.error || `${label} update failed`, "error"); - } else if (data.updated || data.restarting) { - showToast( - data.restarting - ? `${label} updated β€” restarting...` - : `Updated ${label} to ${data.currentVersion}`, - "success", - ); - } else { - showToast(`Already at latest ${label} version`, "success"); - } - } else if (data.hasUpdate && data.latestVersion) { - showToast( - `${label} update available: ${data.latestVersion}`, - "warning", - ); - } else { - showToast(`${label} is up to date`, "success"); - } - } catch (err) { - setError( - err.message || - (effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates"), - ); - showToast( - effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates", - "error", - ); - } - setChecking(false); - }; - - const handleAction = () => { - if (checking) return; - if (effectiveHasUpdate && effectiveLatestVersion && !hasViewedChangelog) { - setConfirmWithoutChangelogOpen(true); - return; - } - runAction(); - }; - - const handleConfirmWithoutChangelog = () => { - setConfirmWithoutChangelogOpen(false); - runAction(); - }; - - return html` -
-
-

- ${label}${" "}${version - ? `${version}` - : "..."} -

- ${error && effectiveHasUpdate && - html`
- ${error} -
`} -
-
- ${effectiveHasUpdate && effectiveLatestVersion && html` - setHasViewedChangelog(true)} - class="hidden md:inline text-xs text-gray-500 hover:text-gray-300 transition-colors" - >View changelog - `} - ${showMobileUpdateRow - ? html` - <${UpdateActionButton} - onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${effectiveHasUpdate - ? `Update to ${effectiveLatestVersion || "latest"}` - : "Check updates"} - loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."} - className="hidden md:inline-flex" - /> - ` - : html` - <${UpdateActionButton} - onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${effectiveHasUpdate - ? `Update to ${effectiveLatestVersion || "latest"}` - : "Check updates"} - loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."} - /> - `} -
-
- ${showMobileUpdateRow && html` -
- setHasViewedChangelog(true)} - class="inline-flex items-center justify-center flex-1 h-9 text-xs rounded-lg border border-border text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-colors" - >View changelog - <${UpdateActionButton} - onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${`Update to ${effectiveLatestVersion || "latest"}`} - loadingLabel="Updating..." - className="flex-1 h-9 px-3" - /> -
- `} - <${ConfirmDialog} - visible=${confirmWithoutChangelogOpen} - title="Update without changelog?" - message="Are you sure you want to update without viewing the changelog?" - confirmLabel=${`Update to ${effectiveLatestVersion || "latest"}`} - cancelLabel="Cancel" - confirmTone="warning" - onCancel=${() => setConfirmWithoutChangelogOpen(false)} - onConfirm=${handleConfirmWithoutChangelog} - /> - `; -} - -export function Gateway({ +export const Gateway = ({ status, - openclawVersion, 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 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 === "degraded" || watchdogStatus?.health === "unhealthy" || watchdogStatus?.health === "crashed"; const liveUptimeMs = useMemo(() => { @@ -288,32 +77,28 @@ export function Gateway({
Gateway: - ${restarting ? "restarting..." : status || "checking..."} + ${restarting ? "restarting..." : status || "checking..."} +
+
${!restarting && isRunning ? html` - - ` : null} + <${UpdateActionButton} + onClick=${onRestart} + disabled=${!status} + loading=${restarting} + warning=${false} + idleLabel="Restart" + loadingLabel="On it..." + />
- <${UpdateActionButton} - onClick=${onRestart} - disabled=${!status} - loading=${restarting} - warning=${false} - idleLabel="Restart" - loadingLabel="On it..." - />
- ${!restarting && isRunning - ? html` -
- Uptime: ${formatDuration(liveUptimeMs)} -
- ` - : null}
${onOpenWatchdog ? html` @@ -322,49 +107,58 @@ export function Gateway({ onclick=${onOpenWatchdog} title="Open Watchdog tab" > - + Watchdog: - ${watchdogLabel} + ${watchdogLabel} ` : html`
- + Watchdog: - ${watchdogLabel} + ${watchdogLabel}
`} ${onRepair ? html`
- ${showRepairButton + ${showInspectButton ? html` <${UpdateActionButton} - onClick=${onRepair} - loading=${isRepairInProgress} - warning=${true} - idleLabel="Repair" - loadingLabel="Repairing..." + onClick=${onOpenWatchdog} + warning=${false} + idleLabel="Inspect" + loadingLabel="Inspect" className="w-full justify-center" /> ` - : html``} + : showRepairButton + ? html` + <${UpdateActionButton} + onClick=${onRepair} + loading=${isRepairInProgress} + warning=${true} + idleLabel="Repair" + loadingLabel="Repairing..." + className="w-full justify-center" + /> + ` + : html``}
` : null}
-
- <${VersionRow} - label="OpenClaw" - currentVersion=${openclawVersion} - fetchVersion=${fetchOpenclawVersion} - applyUpdate=${updateOpenclaw} - /> -
`; -} +}; diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js new file mode 100644 index 00000000..c9841a74 --- /dev/null +++ b/lib/public/js/components/general/index.js @@ -0,0 +1,206 @@ +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; + + 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=${state.hasUnpaired} + statusRefreshing=${state.pairingStatusRefreshing} + onApprove=${actions.handleApprove} + onReject=${actions.handleReject} + /> + `} + `} + /> + <${Features} onSwitchTab=${onSwitchTab} /> + <${Google} + gatewayStatus=${state.gatewayStatus} + onRestartRequired=${onRestartRequired} + onOpenGmailWebhook=${onOpenGmailWebhook} + /> + + ${state.repo && + html` +
+
+
+ + + + ${state.repo} +
+
+ 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} + /> +
+
+ `; +}; diff --git a/lib/public/js/components/general/use-general-tab.js b/lib/public/js/components/general/use-general-tab.js new file mode 100644 index 00000000..fb947584 --- /dev/null +++ b/lib/public/js/components/general/use-general-tab.js @@ -0,0 +1,302 @@ +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 diagnostics = status?.diagnostics || 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 pairingsPoll = usePolling( + async () => { + const data = await fetchPairings(); + return data.pending || []; + }, + 3000, + { + enabled: hasUnpaired && gatewayStatus === "running", + cacheKey: "/api/pairings", + }, + ); + 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 (!isActive) return; + pairingsPoll.refresh(); + if (shouldPollDevices) { + devicePoll.refresh(); + } + }, [ + devicePoll.refresh, + isActive, + onRefreshStatuses, + pairingsPoll.refresh, + devicePollingEnabled, + shouldPollDevices, + ]); + + useEffect(() => { + if (!restartSignal || !isActive) return; + onRefreshStatuses(); + pairingsPoll.refresh(); + 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(); + 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 = "") => { + await approvePairing(id, channel, accountId); + refreshAfterPairingAction(); + }; + + const handleReject = async (id, channel, accountId = "") => { + try { + await rejectPairing(id, channel, accountId); + refreshAfterPairingAction(); + } catch (err) { + showToast(err.message || "Could not reject pairing", "error"); + } + }; + + const handleDeviceApprove = async (id) => { + await approveDevice(id); + setTimeout(devicePoll.refresh, 500); + setTimeout(devicePoll.refresh, 2000); + }; + + const handleDeviceReject = async (id) => { + await rejectDevice(id); + setTimeout(devicePoll.refresh, 500); + setTimeout(devicePoll.refresh, 2000); + }; + + 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(); + console.log("[dashboard] response:", JSON.stringify(data)); + window.open(data.url || "/openclaw", "_blank"); + } catch (err) { + console.error("[dashboard] error:", err); + window.open("/openclaw", "_blank"); + } finally { + setDashboardLoading(false); + } + }; + + return { + state: { + channels, + dashboardLoading, + devicePending, + doctorStatus, + diagnostics, + gatewayStatus, + hasUnpaired, + openclawVersion, + pending, + pairingStatusRefreshing, + repairingWatchdog, + repo, + savingSyncCron, + syncCron, + syncCronChoice, + syncCronEnabled, + syncCronSchedule, + syncCronStatusText: syncCronEnabled ? "Enabled" : "Disabled", + watchdogStatus, + }, + actions: { + handleApprove, + handleDeviceApprove, + handleDeviceReject, + handleOpenDashboard, + handleReject, + handleSyncCronChoiceChange, + handleWatchdogRepair, + }, + }; +}; diff --git a/lib/public/js/components/global-restart-banner.js b/lib/public/js/components/global-restart-banner.js index 98b1e522..d897f5b6 100644 --- a/lib/public/js/components/global-restart-banner.js +++ b/lib/public/js/components/global-restart-banner.js @@ -1,6 +1,7 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +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); @@ -8,6 +9,7 @@ export const GlobalRestartBanner = ({ visible = false, restarting = false, onRestart, + onDismiss = () => {}, }) => { if (!visible) return null; return html` @@ -16,15 +18,26 @@ export const GlobalRestartBanner = ({

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" - /> +
+ <${UpdateActionButton} + onClick=${onRestart} + disabled=${restarting} + loading=${restarting} + warning=${true} + idleLabel="Restart Gateway" + loadingLabel="Restarting..." + className="global-restart-banner__button" + /> + +
`; diff --git a/lib/public/js/components/google.js b/lib/public/js/components/google.js deleted file mode 100644 index 726fe808..00000000 --- a/lib/public/js/components/google.js +++ /dev/null @@ -1,228 +0,0 @@ -import { h } from "https://esm.sh/preact"; -import { useState, useEffect, useCallback } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; -import { - fetchGoogleStatus, - checkGoogleApis as checkApis, - disconnectGoogle as apiDisconnect, -} from "../lib/api.js"; -import { - ScopePicker, - toggleScopeLogic, - getDefaultScopes, -} from "./scope-picker.js"; -import { CredentialsModal } from "./credentials-modal.js"; -import { ConfirmDialog } from "./confirm-dialog.js"; -import { showToast } from "./toast.js"; -const html = htm.bind(h); - -export function Google({ gatewayStatus }) { - const [google, setGoogle] = useState(null); - const [scopes, setScopes] = useState(getDefaultScopes()); - const [savedScopes, setSavedScopes] = useState(null); - const [apiStatus, setApiStatus] = useState({}); - const [checkingApis, setCheckingApis] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false); - - const runApiCheck = useCallback(async () => { - setApiStatus({}); - setCheckingApis(true); - try { - const check = await checkApis(); - if (check.results) setApiStatus(check.results); - } finally { - setCheckingApis(false); - } - }, []); - - const refresh = useCallback(async () => { - try { - const data = await fetchGoogleStatus(); - setGoogle(data); - if (data.activeScopes && data.activeScopes.length > 0) { - setScopes(data.activeScopes); - setSavedScopes(data.activeScopes); - } - if (data.authenticated) { - await runApiCheck(); - } - } catch {} - }, [runApiCheck]); - - useEffect(() => { - refresh(); - }, [refresh]); - - // First load can race gateway startup; refresh when gateway becomes healthy. - useEffect(() => { - if (gatewayStatus === "running") { - refresh(); - } - }, [gatewayStatus, refresh]); - - // Listen for OAuth popup postMessage - useEffect(() => { - const handler = async (e) => { - if (e.data?.google === "success") { - showToast("βœ“ Google account connected", "success"); - setApiStatus({}); - await refresh(); - } else if (e.data?.google === "error") { - showToast( - "βœ— Google auth failed: " + (e.data.message || "unknown"), - "error", - ); - } - }; - window.addEventListener("message", handler); - return () => window.removeEventListener("message", handler); - }, [refresh]); - - const handleToggle = (scope) => { - setScopes((prev) => toggleScopeLogic(prev, scope)); - }; - - const startAuth = (email) => { - if (scopes.length === 0) { - alert("Select at least one service"); - return; - } - const authUrl = `/auth/google/start?email=${encodeURIComponent(email)}&services=${scopes.join(",")}&_ts=${Date.now()}`; - const popup = window.open( - authUrl, - "google-auth", - "popup=yes,width=500,height=700", - ); - if (!popup || popup.closed) window.location.href = authUrl; - }; - - const handleCheckApis = () => runApiCheck(); - - const handleDisconnect = async () => { - const data = await apiDisconnect(); - if (data.ok) { - setGoogle({ - hasCredentials: false, - authenticated: false, - email: "", - services: "", - activeScopes: [], - }); - setApiStatus({}); - setScopes(getDefaultScopes()); - showToast("Google account disconnected", "success"); - } else { - alert("Failed to disconnect: " + (data.error || "unknown")); - } - }; - - if (!google) { - return html`
-

Google Workspace

-
Loading...
-
`; - } - - const hasCredentials = google.authenticated || google.hasCredentials; - const isAuthed = google.authenticated; - const email = google.email || ""; - const scopesChanged = - !savedScopes || - scopes.length !== savedScopes.length || - scopes.some((s) => !savedScopes.includes(s)); - - return html` -
-

Google Workspace

- ${hasCredentials - ? html` -
-
-
${email}
- ${isAuthed - ? html`Connected` - : html`Awaiting sign-in`} -
-
- Select permissions - ${isAuthed && - html``} -
- <${ScopePicker} - scopes=${scopes} - onToggle=${handleToggle} - apiStatus=${isAuthed ? apiStatus : {}} - loading=${isAuthed && checkingApis} - /> -
-
- - -
- -
-
- ` - : html` -
-

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

- -
- `} -
- <${CredentialsModal} - visible=${modalOpen} - onClose=${() => setModalOpen(false)} - onSaved=${refresh} - /> - <${ConfirmDialog} - visible=${disconnectDialogOpen} - 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=${() => setDisconnectDialogOpen(false)} - onConfirm=${async () => { - setDisconnectDialogOpen(false); - await handleDisconnect(); - }} - /> - `; -} diff --git a/lib/public/js/components/google/account-row.js b/lib/public/js/components/google/account-row.js new file mode 100644 index 00000000..de3ee79c --- /dev/null +++ b/lib/public/js/components/google/account-row.js @@ -0,0 +1,131 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/google/add-account-modal.js b/lib/public/js/components/google/add-account-modal.js new file mode 100644 index 00000000..b2e0211d --- /dev/null +++ b/lib/public/js/components/google/add-account-modal.js @@ -0,0 +1,93 @@ +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" + /> +
+ `; +}; diff --git a/lib/public/js/components/google/gmail-setup-wizard.js b/lib/public/js/components/google/gmail-setup-wizard.js new file mode 100644 index 00000000..b337127c --- /dev/null +++ b/lib/public/js/components/google/gmail-setup-wizard.js @@ -0,0 +1,512 @@ +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" + />` + } +
+ + `; +}; diff --git a/lib/public/js/components/google/gmail-watch-toggle.js b/lib/public/js/components/google/gmail-watch-toggle.js new file mode 100644 index 00000000..cabff767 --- /dev/null +++ b/lib/public/js/components/google/gmail-watch-toggle.js @@ -0,0 +1,84 @@ +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?.(); + }} + /> +
+
+ `; +}; diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js new file mode 100644 index 00000000..3045f247 --- /dev/null +++ b/lib/public/js/components/google/index.js @@ -0,0 +1,587 @@ +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); + }} + /> + `; +}; diff --git a/lib/public/js/components/google/use-gmail-watch.js b/lib/public/js/components/google/use-gmail-watch.js new file mode 100644 index 00000000..28b40b95 --- /dev/null +++ b/lib/public/js/components/google/use-gmail-watch.js @@ -0,0 +1,144 @@ +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, + }; +}; diff --git a/lib/public/js/components/google/use-google-accounts.js b/lib/public/js/components/google/use-google-accounts.js new file mode 100644 index 00000000..4e77eec4 --- /dev/null +++ b/lib/public/js/components/google/use-google-accounts.js @@ -0,0 +1,41 @@ +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, + }; +}; diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 49591620..abe691d1 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -1,5 +1,5 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; const html = htm.bind(h); @@ -40,3 +40,530 @@ 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` + +`; diff --git a/lib/public/js/components/info-tooltip.js b/lib/public/js/components/info-tooltip.js index fd5c495c..01a20b23 100644 --- a/lib/public/js/components/info-tooltip.js +++ b/lib/public/js/components/info-tooltip.js @@ -1,18 +1,15 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +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}> ? - - + `; diff --git a/lib/public/js/components/loading-spinner.js b/lib/public/js/components/loading-spinner.js index 5dcd09f3..6a5105b7 100644 --- a/lib/public/js/components/loading-spinner.js +++ b/lib/public/js/components/loading-spinner.js @@ -1,5 +1,5 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; const html = htm.bind(h); diff --git a/lib/public/js/components/modal-shell.js b/lib/public/js/components/modal-shell.js index 5327aaba..ea618a26 100644 --- a/lib/public/js/components/modal-shell.js +++ b/lib/public/js/components/modal-shell.js @@ -1,7 +1,7 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect } from "https://esm.sh/preact/hooks"; -import { createPortal } from "https://esm.sh/preact/compat"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import { createPortal } from "preact/compat"; +import htm from "htm"; const html = htm.bind(h); @@ -13,6 +13,8 @@ export const ModalShell = ({ 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; @@ -29,9 +31,23 @@ export const ModalShell = ({ 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; + }} onclick=${(event) => { - if (closeOnOverlayClick && event.target === event.currentTarget) onClose?.(); + event.preventDefault(); }} >
${children}
diff --git a/lib/public/js/components/models-tab/index.js b/lib/public/js/components/models-tab/index.js new file mode 100644 index 00000000..eb1c8ea3 --- /dev/null +++ b/lib/public/js/components/models-tab/index.js @@ -0,0 +1,281 @@ +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, + 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); + const provider = getModelsTabAuthProvider(key); + const hasAuth = !!providerHasAuth[provider]; + return { + key, + label: catalogEntry?.label || key, + 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} + 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} + + `; +}; diff --git a/lib/public/js/components/models-tab/model-picker.js b/lib/public/js/components/models-tab/model-picker.js new file mode 100644 index 00000000..fd83b4aa --- /dev/null +++ b/lib/public/js/components/models-tab/model-picker.js @@ -0,0 +1,212 @@ +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(); + +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 = [], + 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 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` + + `, + )} +
+ `, + ) + : html` +
+ No models match that search. +
+ `} +
+
+ ` + : null} +
+ `; +}; diff --git a/lib/public/js/components/models-tab/provider-auth-card.js b/lib/public/js/components/models-tab/provider-auth-card.js new file mode 100644 index 00000000..707a4fbf --- /dev/null +++ b/lib/public/js/components/models-tab/provider-auth-card.js @@ -0,0 +1,422 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/models-tab/use-models.js b/lib/public/js/components/models-tab/use-models.js new file mode 100644 index 00000000..3afaafb5 --- /dev/null +++ b/lib/public/js/components/models-tab/use-models.js @@ -0,0 +1,380 @@ +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, + }; +}; diff --git a/lib/public/js/components/models.js b/lib/public/js/components/models.js index 2be55e74..e0001c18 100644 --- a/lib/public/js/components/models.js +++ b/lib/public/js/components/models.js @@ -1,6 +1,6 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import htm from "htm"; import { fetchEnvVars, saveEnvVars, @@ -24,6 +24,10 @@ import { kProviderLabels, kProviderOrder, } from "../lib/model-config.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../lib/codex-oauth-window.js"; const html = htm.bind(h); @@ -51,6 +55,7 @@ export const Models = () => { 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 () => { @@ -122,22 +127,50 @@ export const Models = () => { } }, []); + 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 next = prev.map((v) => (v.key === key ? { ...v, value } : v)); + 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; }); @@ -191,10 +224,9 @@ export const Models = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780"); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -210,21 +242,7 @@ export const Models = () => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - 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) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -263,28 +281,24 @@ export const Models = () => { (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = - selectedModelProvider === "anthropic" - ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN")) - : selectedModelProvider === "openai" - ? !!getKeyVal(envVars, "OPENAI_API_KEY") - : selectedModelProvider === "openai-codex" - ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY")) - : selectedModelProvider === "google" - ? !!getKeyVal(envVars, "GEMINI_API_KEY") - : false; + 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-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono" + 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}

+

${field.hint}

`; @@ -297,12 +311,28 @@ export const Models = () => { html`
- Codex OAuth + Codex OAuth ${codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
- ${codexStatus.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`
` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
-

- ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

- -
`} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html` -

+

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

{ value=${codexManualInput} onInput=${(e) => setCodexManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." - class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500" + 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} @@ -377,7 +391,7 @@ export const Models = () => { if (!ready) { return html`
-
+
<${LoadingSpinner} className="h-4 w-4" /> Loading model settings...
@@ -397,14 +411,14 @@ export const Models = () => { setModelDirty(next !== savedModel); kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: next }; }} - class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500" + class="w-full bg-field border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-body outline-none focus:border-fg-muted" > ${modelOptions.map( (model) => html``, )} -

+

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

${canToggleFullCatalog @@ -418,7 +432,7 @@ export const Models = () => { kModelsTabCache = { ...(kModelsTabCache || {}), showAllModels: next }; return next; })} - class="text-xs text-gray-500 hover:text-gray-300" + class="text-xs text-fg-muted hover:text-body" > ${showAllModels ? "Show recommended models" : "Show full model catalog"} @@ -434,8 +448,8 @@ export const Models = () => {

Other Providers

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

${kProviderLabels[provider] || provider}

+
+

${kProviderLabels[provider] || provider}

${renderProviderContent(provider)}
`, @@ -454,7 +468,7 @@ export const Models = () => { /> ${modelDirty && !hasSelectedProviderAuth ? html` -

+

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

` diff --git a/lib/public/js/components/nodes-tab/browser-attach/index.js b/lib/public/js/components/nodes-tab/browser-attach/index.js new file mode 100644 index 00000000..3db2859b --- /dev/null +++ b/lib/public/js/components/nodes-tab/browser-attach/index.js @@ -0,0 +1,75 @@ +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 + +
+
+ `; +}; diff --git a/lib/public/js/components/nodes-tab/connected-nodes/index.js b/lib/public/js/components/nodes-tab/connected-nodes/index.js new file mode 100644 index 00000000..9ab806c4 --- /dev/null +++ b/lib/public/js/components/nodes-tab/connected-nodes/index.js @@ -0,0 +1,442 @@ +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} + /> + `; +}; diff --git a/lib/public/js/components/nodes-tab/connected-nodes/use-connected-nodes-card.js b/lib/public/js/components/nodes-tab/connected-nodes/use-connected-nodes-card.js new file mode 100644 index 00000000..58eb4662 --- /dev/null +++ b/lib/public/js/components/nodes-tab/connected-nodes/use-connected-nodes-card.js @@ -0,0 +1,317 @@ +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, + }; +}; diff --git a/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js b/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js new file mode 100644 index 00000000..c9ef4f5e --- /dev/null +++ b/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js @@ -0,0 +1,25 @@ +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" }, + ); + + 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, + }; +}; diff --git a/lib/public/js/components/nodes-tab/exec-allowlist/index.js b/lib/public/js/components/nodes-tab/exec-allowlist/index.js new file mode 100644 index 00000000..fac605fd --- /dev/null +++ b/lib/public/js/components/nodes-tab/exec-allowlist/index.js @@ -0,0 +1,89 @@ +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" + /> +
+ `, + )} +
+ `} +
+ `; +}; diff --git a/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js b/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js new file mode 100644 index 00000000..49c8d649 --- /dev/null +++ b/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js @@ -0,0 +1,78 @@ +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, + }; +}; diff --git a/lib/public/js/components/nodes-tab/exec-config/index.js b/lib/public/js/components/nodes-tab/exec-config/index.js new file mode 100644 index 00000000..5e1d7722 --- /dev/null +++ b/lib/public/js/components/nodes-tab/exec-config/index.js @@ -0,0 +1,118 @@ +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)} + /> +
+
+ `; +}; diff --git a/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js b/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js new file mode 100644 index 00000000..bde07a7b --- /dev/null +++ b/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js @@ -0,0 +1,79 @@ +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, + }; +}; diff --git a/lib/public/js/components/nodes-tab/index.js b/lib/public/js/components/nodes-tab/index.js new file mode 100644 index 00000000..a6004cc1 --- /dev/null +++ b/lib/public/js/components/nodes-tab/index.js @@ -0,0 +1,57 @@ +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} + /> +
+ `; +}; diff --git a/lib/public/js/components/nodes-tab/setup-wizard/index.js b/lib/public/js/components/nodes-tab/setup-wizard/index.js new file mode 100644 index 00000000..bb7f814e --- /dev/null +++ b/lib/public/js/components/nodes-tab/setup-wizard/index.js @@ -0,0 +1,219 @@ +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" + /> + ` + } +
+ + `; +}; diff --git a/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js b/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js new file mode 100644 index 00000000..f9ae257e --- /dev/null +++ b/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js @@ -0,0 +1,220 @@ +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, + }; +}; diff --git a/lib/public/js/components/nodes-tab/use-nodes-tab.js b/lib/public/js/components/nodes-tab/use-nodes-tab.js new file mode 100644 index 00000000..7b8da280 --- /dev/null +++ b/lib/public/js/components/nodes-tab/use-nodes-tab.js @@ -0,0 +1,55 @@ +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, + }, + }; +}; diff --git a/lib/public/js/components/onboarding/pairing-utils.js b/lib/public/js/components/onboarding/pairing-utils.js index b27e6574..02f594a4 100644 --- a/lib/public/js/components/onboarding/pairing-utils.js +++ b/lib/public/js/components/onboarding/pairing-utils.js @@ -1,6 +1,7 @@ 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 ""; }; diff --git a/lib/public/js/components/onboarding/use-welcome-codex.js b/lib/public/js/components/onboarding/use-welcome-codex.js new file mode 100644 index 00000000..17fba712 --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-codex.js @@ -0,0 +1,139 @@ +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, + }; +}; diff --git a/lib/public/js/components/onboarding/use-welcome-pairing.js b/lib/public/js/components/onboarding/use-welcome-pairing.js new file mode 100644 index 00000000..15edc9ef --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-pairing.js @@ -0,0 +1,74 @@ +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 }, + ); + 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, + }; +}; diff --git a/lib/public/js/components/onboarding/use-welcome-storage.js b/lib/public/js/components/onboarding/use-welcome-storage.js new file mode 100644 index 00000000..06721731 --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-storage.js @@ -0,0 +1,61 @@ +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, + }; +}; diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index 0be58a1f..71d95958 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -1,9 +1,19 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +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 kGithubFlowReadOnly = "read-only"; +export const kGithubTargetRepoModeCreate = "create"; +export const kGithubTargetRepoModeExistingEmpty = "existing-empty"; + +const hasValue = (value) => !!String(value || "").trim(); + export const normalizeGithubRepoInput = (repoInput) => String(repoInput || "") .trim() @@ -18,45 +28,121 @@ export const isValidGithubRepoInput = (repoInput) => { 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: "ai", - title: "Primary Agent Model", - description: "Choose your main model and authenticate its provider", - fields: kAllAiAuthFields, - validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi), - }, { id: "github", title: "GitHub", - description: "Backs up your agent's config and workspace", + 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: "Workspace Repo", - hint: "A new private repo will be created for you", + label: "New Workspace Repo", placeholder: "username/my-agent", isText: true, }, { key: "GITHUB_TOKEN", label: "Personal Access Token", - hint: html`Create a classic PAT on${" "}GitHub settings${" "}with${" "}classic PAT${" "}with${" "}repo${" "}scope`, - placeholder: "ghp_...", + >${" "}scope, or a${" "}fine-grained token${" "}with Contents + Metadata access`, + placeholder: "ghp_... or github_pat_...", }, ], - validate: (vals) => - !!( - vals.GITHUB_TOKEN && isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO) - ), + 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", @@ -99,8 +185,38 @@ export const kWelcomeGroups = [ >`, 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) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN), + validate: (vals, ctx = {}) => !getWelcomeGroupError("channels", vals, ctx), }, { id: "tools", @@ -123,3 +239,6 @@ export const kWelcomeGroups = [ validate: () => true, }, ]; + +export const findFirstInvalidWelcomeGroup = (vals, ctx = {}) => + kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null; diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 98dccace..94e49e55 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -1,12 +1,28 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect, useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +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 { isValidGithubRepoInput } from "./welcome-config.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, @@ -34,22 +50,28 @@ export const WelcomeFormStep = ({ error, step, totalGroups, - currentGroupValid, goBack, goNext, loading, githubStepLoading, - allValid, handleSubmit, }) => { - const [repoTouched, setRepoTouched] = useState(false); 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") { - setRepoTouched(false); - } + if (activeGroup.id !== "github") return; }, [activeGroup.id]); useEffect(() => { @@ -58,21 +80,141 @@ export const WelcomeFormStep = ({ 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.title}

+

${activeGroup.description}

${activeGroup.validate(vals, { hasAi }) ? html`βœ“` : activeGroup.id !== "tools" ? html`Required` : null} @@ -81,11 +223,11 @@ export const WelcomeFormStep = ({ ${activeGroup.id === "ai" && html`
- + -

+

${modelsLoading ? "Loading model catalog..." : modelsError @@ -106,7 +248,7 @@ export const WelcomeFormStep = ({ ` + ${step >= 0 + ? html`<${ActionButton} + onClick=${goBack} + tone="secondary" + size="md" + idleLabel="Back" + className="w-full" + />` : html`

`} - + loadingLabel="Checking..." + className="w-full" + /> ` : html` - ${step > 0 - ? 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" + /> `}
`; diff --git a/lib/public/js/components/onboarding/welcome-header.js b/lib/public/js/components/onboarding/welcome-header.js index bfb60b49..6df96461 100644 --- a/lib/public/js/components/onboarding/welcome-header.js +++ b/lib/public/js/components/onboarding/welcome-header.js @@ -1,11 +1,12 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; const html = htm.bind(h); export const WelcomeHeader = ({ groups, step, + isPreStep, isSetupStep, isPairingStep, stepNumber, @@ -19,23 +20,23 @@ export const WelcomeHeader = ({ return html`
- alphaclaw +

Setup

Let's get your agent running

-
+
- Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel} + ${isPreStep + ? "Choose your destiny" + : `Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel}`}
@@ -43,21 +44,24 @@ export const WelcomeHeader = ({
${progressSteps.map((group, idx) => { const isActive = idx === step; - const isComplete = idx < step || (isSetupStep && group.id === "setup"); + const isComplete = + idx < step || (isSetupStep && group.id === "setup"); const isPairingComplete = idx < step || (isPairingStep && group.id === "pairing"); - const bg = isActive - ? "rgba(99, 235, 255, 0.9)" - : group.id === "pairing" - ? isPairingComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)" - : isComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)"; + 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`
diff --git a/lib/public/js/components/onboarding/welcome-import-step.js b/lib/public/js/components/onboarding/welcome-import-step.js new file mode 100644 index 00000000..729c10c9 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-import-step.js @@ -0,0 +1,308 @@ +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" + /> +
+
+ `; +}; diff --git a/lib/public/js/components/onboarding/welcome-pairing-step.js b/lib/public/js/components/onboarding/welcome-pairing-step.js index efff9094..3e3a4d04 100644 --- a/lib/public/js/components/onboarding/welcome-pairing-step.js +++ b/lib/public/js/components/onboarding/welcome-pairing-step.js @@ -1,6 +1,6 @@ -import { h } from "https://esm.sh/preact"; -import { useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import { useState } from "preact/hooks"; +import htm from "htm"; import { Badge } from "../badge.js"; const html = htm.bind(h); @@ -14,6 +14,14 @@ const kChannelMeta = { 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 }) => { @@ -22,7 +30,7 @@ const PairingRow = ({ pairing, onApprove, onReject }) => { const handleApprove = async () => { setBusyAction("approve"); try { - await onApprove(pairing.id, pairing.channel); + await onApprove(pairing.id, pairing.channel, pairing.accountId || ""); } finally { setBusyAction(""); } @@ -31,37 +39,43 @@ const PairingRow = ({ pairing, onApprove, onReject }) => { const handleReject = async () => { setBusyAction("reject"); try { - await onReject(pairing.id, pairing.channel); + 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.

@@ -73,24 +87,27 @@ const PairingRow = ({ pairing, onApprove, onReject }) => { export const WelcomePairingStep = ({ channel, pairings, - channels, loading, error, onApprove, onReject, canFinish, onContinue, + onSkip, }) => { const channelMeta = kChannelMeta[channel] || { - label: channel ? channel.charAt(0).toUpperCase() + channel.slice(1) : "Channel", + label: channel + ? channel.charAt(0).toUpperCase() + channel.slice(1) + : "Channel", iconSrc: "", }; - const channelInfo = channels?.[channel]; if (!channel) { return html` -
- Missing channel configuration. Go back and add a Telegram or Discord bot token. +
+ Missing channel configuration. Go back and add a channel credential.
`; } @@ -100,12 +117,16 @@ export const WelcomePairingStep = ({
-

πŸŽ‰ Setup complete

-

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

+ πŸŽ‰ Setup complete

-

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

+ 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.

@@ -123,51 +144,74 @@ export const WelcomePairingStep = ({
<${Badge} tone="warning" - >${loading - ? "Checking..." - : pairings.length > 0 - ? "Pairing request detected" - : "Awaiting pairing"}${ + 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 -

-
-
`} + ${ + 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} + ${ + error + ? html`
+ ${error} +
` + : null + } + ${ + pairings.length === 0 + ? html`
+ +
` + : null + }
`; }; diff --git a/lib/public/js/components/onboarding/welcome-placeholder-review-step.js b/lib/public/js/components/onboarding/welcome-placeholder-review-step.js new file mode 100644 index 00000000..57347c29 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-placeholder-review-step.js @@ -0,0 +1,99 @@ +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" + /> +
+
+ `; +}; diff --git a/lib/public/js/components/onboarding/welcome-pre-step.js b/lib/public/js/components/onboarding/welcome-pre-step.js new file mode 100644 index 00000000..508b68e5 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-pre-step.js @@ -0,0 +1,131 @@ +import { h } from "preact"; +import htm from "htm"; +import { + kGithubFlowFresh, + kGithubFlowImport, + kGithubFlowReadOnly, +} from "./welcome-config.js"; + +const html = htm.bind(h); + +export const WelcomePreStep = ({ onSelectFlow }) => { + return html` +
+ + + + + +
+ `; +}; diff --git a/lib/public/js/components/onboarding/welcome-secret-review-step.js b/lib/public/js/components/onboarding/welcome-secret-review-step.js new file mode 100644 index 00000000..24893a68 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-secret-review-step.js @@ -0,0 +1,197 @@ +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" + /> +
+
+ `; +}; diff --git a/lib/public/js/components/onboarding/welcome-secret-review-utils.js b/lib/public/js/components/onboarding/welcome-secret-review-utils.js new file mode 100644 index 00000000..531370b2 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-secret-review-utils.js @@ -0,0 +1,19 @@ +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; + }, + {}, + ); diff --git a/lib/public/js/components/onboarding/welcome-setup-step.js b/lib/public/js/components/onboarding/welcome-setup-step.js index da98f915..2d4c41f8 100644 --- a/lib/public/js/components/onboarding/welcome-setup-step.js +++ b/lib/public/js/components/onboarding/welcome-setup-step.js @@ -1,6 +1,6 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect, useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +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); @@ -45,11 +45,11 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => { if (error) { return html`
-

Setup failed

-

Fix the values and try again.

+

Setup failed

+

Fix the values and try again.

${error}
@@ -83,16 +83,16 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
- <${LoadingSpinner} className="h-8 w-8 text-white" /> -

+ <${LoadingSpinner} className="h-8 w-8 text-body" /> +

Initializing OpenClaw...

-

This could take 10-15 seconds

+

This could take 10-15 seconds

- ${currentTip.label}: + ${currentTip.label}: ${currentTip.text}
diff --git a/lib/public/js/components/overflow-menu.js b/lib/public/js/components/overflow-menu.js new file mode 100644 index 00000000..764e0418 --- /dev/null +++ b/lib/public/js/components/overflow-menu.js @@ -0,0 +1,122 @@ +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` + +`; diff --git a/lib/public/js/components/page-header.js b/lib/public/js/components/page-header.js index e640f2bf..c5b84250 100644 --- a/lib/public/js/components/page-header.js +++ b/lib/public/js/components/page-header.js @@ -1,5 +1,5 @@ -import { h } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import htm from "htm"; const html = htm.bind(h); diff --git a/lib/public/js/components/pairings.js b/lib/public/js/components/pairings.js index 3398590c..bac853b7 100644 --- a/lib/public/js/components/pairings.js +++ b/lib/public/js/components/pairings.js @@ -1,42 +1,48 @@ -import { h } from 'https://esm.sh/preact'; -import { useState } from 'https://esm.sh/preact/hooks'; -import htm from 'https://esm.sh/htm'; +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import htm from 'htm'; import { ActionButton } from './action-button.js'; const html = htm.bind(h); -const PairingRow = ({ p, onApprove, onReject }) => { +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); - else await onReject(p.id, p.channel); + 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} Β· ${p.code || p.id || '?'} +
+ Approved + ${label}${accountSuffix} Β· ${p.code || p.id || '?'}
`; } if (busy === "reject") { return html` -
- Rejected - ${label} Β· ${p.code || p.id || '?'} +
+ Rejected + ${label}${accountSuffix} Β· ${p.code || p.id || '?'}
`; } return html` -
-
${label} Β· ${p.code || p.id || '?'}
+
+
${label}${accountSuffix} Β· ${p.code || p.id || '?'}
<${ActionButton} onClick=${() => handle("approve")} @@ -56,15 +62,33 @@ const PairingRow = ({ p, onApprove, onReject }) => {
`; }; -const ALL_CHANNELS = ['telegram', 'discord']; +const ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp']; const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); -export function Pairings({ pending, channels, visible, onApprove, onReject }) { +export function Pairings({ + pending, + channels, + visible, + onApprove, + onReject, + statusRefreshing = false, +}) { if (!visible) return null; const unpaired = ALL_CHANNELS - .filter((ch) => channels?.[ch] && channels[ch].status !== 'paired') + .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 @@ -78,10 +102,14 @@ export function Pairings({ pending, channels, visible, onApprove, onReject }) { ? html`
${pending.map(p => html`<${PairingRow} key=${p.id} p=${p} onApprove=${onApprove} onReject=${onReject} />`)}
` + : 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

+

Send a message to your bot on ${channelList}

+

The pairing request will appear here β€” it may take a few moments

`}
`; } diff --git a/lib/public/js/components/pane-shell.js b/lib/public/js/components/pane-shell.js new file mode 100644 index 00000000..76588a1e --- /dev/null +++ b/lib/public/js/components/pane-shell.js @@ -0,0 +1,27 @@ +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} +
+
+
+`; diff --git a/lib/public/js/components/pill-tabs.js b/lib/public/js/components/pill-tabs.js new file mode 100644 index 00000000..309f4775 --- /dev/null +++ b/lib/public/js/components/pill-tabs.js @@ -0,0 +1,33 @@ +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` + + `, + )} +
+`; diff --git a/lib/public/js/components/pop-actions.js b/lib/public/js/components/pop-actions.js new file mode 100644 index 00000000..3e1bfa79 --- /dev/null +++ b/lib/public/js/components/pop-actions.js @@ -0,0 +1,58 @@ +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} +
+ `; +}; diff --git a/lib/public/js/components/providers.js b/lib/public/js/components/providers.js index 1e94bb24..d5cc8300 100644 --- a/lib/public/js/components/providers.js +++ b/lib/public/js/components/providers.js @@ -1,6 +1,6 @@ -import { h } from "https://esm.sh/preact"; -import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import htm from "htm"; import { fetchEnvVars, saveEnvVars, @@ -27,6 +27,10 @@ import { kProviderFeatures, kCoreProviders, } from "../lib/model-config.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../lib/codex-oauth-window.js"; const html = htm.bind(h); @@ -48,7 +52,7 @@ const FeatureTags = ({ provider, features = null }) => { ${uniqueFeatures.map( (f) => html` ${f} `, @@ -89,6 +93,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { () => kProvidersTabCache?.savedAiValues || {}, ); const [showMoreProviders, setShowMoreProviders] = useState(false); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { @@ -171,11 +176,37 @@ export const Providers = ({ onRestartRequired = () => {} }) => { [], ); + 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"}`, @@ -185,11 +216,14 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { - const next = prev.map((v) => (v.key === key ? { ...v, value } : v)); + 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; }); @@ -224,18 +258,11 @@ export const Providers = ({ onRestartRequired = () => {} }) => { (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = - selectedModelProvider === "anthropic" - ? !!( - getKeyVal(envVars, "ANTHROPIC_API_KEY") || - getKeyVal(envVars, "ANTHROPIC_TOKEN") - ) - : selectedModelProvider === "openai" - ? !!getKeyVal(envVars, "OPENAI_API_KEY") - : selectedModelProvider === "openai-codex" - ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY")) - : selectedModelProvider === "google" - ? !!getKeyVal(envVars, "GEMINI_API_KEY") - : false; + selectedModelProvider === "openai-codex" + ? !!codexStatus.connected + : (kProviderAuthFields[selectedAuthProvider] || []).some((field) => + Boolean(getKeyVal(envVars, field.key)), + ); const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth)); @@ -300,14 +327,9 @@ export const Providers = ({ onRestartRequired = () => {} }) => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open( - "/auth/codex/start", - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -323,22 +345,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - 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) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -357,7 +364,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { const renderCredentialField = (field) => html`
`; @@ -384,12 +391,28 @@ export const Providers = ({ onRestartRequired = () => {} }) => { const renderCodexOAuth = () => html`
- Codex OAuth + Codex OAuth ${codexStatus.connected ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
- ${codexStatus.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`
` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
-

- ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

- -
`} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html` -

+

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

@@ -443,7 +450,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { value=${codexManualInput} onInput=${(e) => setCodexManualInput(e.target.value)} placeholder="http://localhost:1455/auth/callback?code=...&state=..." - class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500" + 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} @@ -510,7 +517,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { `} />
-
+
<${LoadingSpinner} className="h-4 w-4" /> Loading provider settings...
@@ -559,7 +566,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { selectedModel: next, }; }} - class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500" + class="w-full bg-field border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-body outline-none focus:border-fg-muted" > ${modelOptions.map( @@ -569,7 +576,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { `, )} -

+

${modelsLoading ? "Loading model catalog..." : modelsError @@ -590,7 +597,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; return next; })} - class="text-xs text-gray-500 hover:text-gray-300" + class="text-xs text-fg-muted hover:text-body" > ${showAllModels ? "Show recommended models" @@ -627,7 +634,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { : null} ${modelDirty && !hasSelectedProviderAuth ? html` -

+

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

diff --git a/lib/public/js/components/routes/agents-route.js b/lib/public/js/components/routes/agents-route.js new file mode 100644 index 00000000..13420bb0 --- /dev/null +++ b/lib/public/js/components/routes/agents-route.js @@ -0,0 +1,31 @@ +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} + /> +`; diff --git a/lib/public/js/components/routes/browse-route.js b/lib/public/js/components/routes/browse-route.js new file mode 100644 index 00000000..76ce7e44 --- /dev/null +++ b/lib/public/js/components/routes/browse-route.js @@ -0,0 +1,35 @@ +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} + /> +
+`; diff --git a/lib/public/js/components/routes/chat-route.js b/lib/public/js/components/routes/chat-route.js new file mode 100644 index 00000000..30f8ad7d --- /dev/null +++ b/lib/public/js/components/routes/chat-route.js @@ -0,0 +1,1122 @@ +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} + +
+
+
+ `; +}; diff --git a/lib/public/js/components/routes/cron-route.js b/lib/public/js/components/routes/cron-route.js new file mode 100644 index 00000000..243891d1 --- /dev/null +++ b/lib/public/js/components/routes/cron-route.js @@ -0,0 +1,9 @@ +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} /> +`; diff --git a/lib/public/js/components/routes/doctor-route.js b/lib/public/js/components/routes/doctor-route.js new file mode 100644 index 00000000..4a6ceb3c --- /dev/null +++ b/lib/public/js/components/routes/doctor-route.js @@ -0,0 +1,21 @@ +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 } : {}), + }); + }} + /> +
+`; diff --git a/lib/public/js/components/routes/envars-route.js b/lib/public/js/components/routes/envars-route.js new file mode 100644 index 00000000..4fe57c10 --- /dev/null +++ b/lib/public/js/components/routes/envars-route.js @@ -0,0 +1,9 @@ +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} /> +`; diff --git a/lib/public/js/components/routes/general-route.js b/lib/public/js/components/routes/general-route.js new file mode 100644 index 00000000..0fe8c31b --- /dev/null +++ b/lib/public/js/components/routes/general-route.js @@ -0,0 +1,41 @@ +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} + /> +
+`; diff --git a/lib/public/js/components/routes/index.js b/lib/public/js/components/routes/index.js new file mode 100644 index 00000000..312aeb2f --- /dev/null +++ b/lib/public/js/components/routes/index.js @@ -0,0 +1,15 @@ +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"; diff --git a/lib/public/js/components/routes/models-route.js b/lib/public/js/components/routes/models-route.js new file mode 100644 index 00000000..49635779 --- /dev/null +++ b/lib/public/js/components/routes/models-route.js @@ -0,0 +1,9 @@ +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} /> +`; diff --git a/lib/public/js/components/routes/nodes-route.js b/lib/public/js/components/routes/nodes-route.js new file mode 100644 index 00000000..2b897aae --- /dev/null +++ b/lib/public/js/components/routes/nodes-route.js @@ -0,0 +1,11 @@ +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} /> +
+`; diff --git a/lib/public/js/components/routes/providers-route.js b/lib/public/js/components/routes/providers-route.js new file mode 100644 index 00000000..c5fe5c52 --- /dev/null +++ b/lib/public/js/components/routes/providers-route.js @@ -0,0 +1,11 @@ +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} /> +
+`; diff --git a/lib/public/js/components/routes/route-redirect.js b/lib/public/js/components/routes/route-redirect.js new file mode 100644 index 00000000..55f3bb59 --- /dev/null +++ b/lib/public/js/components/routes/route-redirect.js @@ -0,0 +1,10 @@ +import { useEffect } from "preact/hooks"; +import { useLocation } from "wouter-preact"; + +export const RouteRedirect = ({ to }) => { + const [, setLocation] = useLocation(); + useEffect(() => { + setLocation(to); + }, [to, setLocation]); + return null; +}; diff --git a/lib/public/js/components/routes/telegram-route.js b/lib/public/js/components/routes/telegram-route.js new file mode 100644 index 00000000..b9e13e4c --- /dev/null +++ b/lib/public/js/components/routes/telegram-route.js @@ -0,0 +1,11 @@ +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} /> +
+`; diff --git a/lib/public/js/components/routes/usage-route.js b/lib/public/js/components/routes/usage-route.js new file mode 100644 index 00000000..27940b9c --- /dev/null +++ b/lib/public/js/components/routes/usage-route.js @@ -0,0 +1,15 @@ +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")} + /> +
+`; diff --git a/lib/public/js/components/routes/watchdog-route.js b/lib/public/js/components/routes/watchdog-route.js new file mode 100644 index 00000000..0bbe08bc --- /dev/null +++ b/lib/public/js/components/routes/watchdog-route.js @@ -0,0 +1,27 @@ +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} + diagnostics=${statusData?.diagnostics || null} + watchdogStatus=${watchdogStatus} + onRefreshStatuses=${onRefreshStatuses} + restartingGateway=${restartingGateway} + onRestartGateway=${onRestartGateway} + restartSignal=${restartSignal} + /> +
+`; diff --git a/lib/public/js/components/routes/webhooks-route.js b/lib/public/js/components/routes/webhooks-route.js new file mode 100644 index 00000000..d62ba559 --- /dev/null +++ b/lib/public/js/components/routes/webhooks-route.js @@ -0,0 +1,43 @@ +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" })} + /> +
+ `; +}; diff --git a/lib/public/js/components/scope-picker.js b/lib/public/js/components/scope-picker.js index 5b04db64..c1767e4f 100644 --- a/lib/public/js/components/scope-picker.js +++ b/lib/public/js/components/scope-picker.js @@ -1,6 +1,6 @@ -import { h } from 'https://esm.sh/preact'; -import { useState } from 'https://esm.sh/preact/hooks'; -import htm from 'https://esm.sh/htm'; +import { h } from 'preact'; +import { useState } from 'preact/hooks'; +import htm from 'htm'; const html = htm.bind(h); export const SERVICES = [ @@ -43,19 +43,19 @@ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) { const api = status[s.key]; let apiIndicator = null; if (loading && !api && (readOn || writeOn)) { - apiIndicator = html``; + apiIndicator = html``; } else if (api) { if (api.status === 'ok') { - apiIndicator = html`
API βœ“`; + apiIndicator = html`
API βœ“`; } else if (api.status === 'not_enabled') { - apiIndicator = html`Enable API`; + apiIndicator = html`Enable API`; } else if (api.status === 'error') { - apiIndicator = html`Enable API`; + apiIndicator = html`Enable API`; } } return html` -
+
${s.icon} ${s.label}
${apiIndicator} @@ -68,7 +68,7 @@ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) { diff --git a/lib/public/js/components/secret-input.js b/lib/public/js/components/secret-input.js index ed379f34..e927b727 100644 --- a/lib/public/js/components/secret-input.js +++ b/lib/public/js/components/secret-input.js @@ -1,6 +1,7 @@ -import { h } from "https://esm.sh/preact"; -import { useState } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +import { h } from "preact"; +import { useState } from "preact/hooks"; +import htm from "htm"; +import { LoadingSpinner } from "./loading-spinner.js"; const html = htm.bind(h); /** @@ -17,10 +18,12 @@ export const SecretInput = ({ placeholder = "", inputClass = "", disabled = false, + loading = false, isSecret = true, }) => { const [visible, setVisible] = useState(false); const showToggle = isSecret; + const isDisabled = disabled || loading; return html`
@@ -30,15 +33,19 @@ export const SecretInput = ({ placeholder=${placeholder} onInput=${onInput} onBlur=${onBlur} - disabled=${disabled} + disabled=${isDisabled} class=${inputClass} autocomplete="off" /> + ${loading + ? html`<${LoadingSpinner} className="h-3 w-3 text-fg-muted shrink-0" />` + : null} ${showToggle ? html`` diff --git a/lib/public/js/components/segmented-control.js b/lib/public/js/components/segmented-control.js new file mode 100644 index 00000000..ea300c5e --- /dev/null +++ b/lib/public/js/components/segmented-control.js @@ -0,0 +1,45 @@ +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; + }, + )} +
+`; diff --git a/lib/public/js/components/session-select-field.js b/lib/public/js/components/session-select-field.js new file mode 100644 index 00000000..2501f5d3 --- /dev/null +++ b/lib/public/js/components/session-select-field.js @@ -0,0 +1,83 @@ +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 + } +
+ `; +}; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js new file mode 100644 index 00000000..7397534f --- /dev/null +++ b/lib/public/js/components/sidebar-git-panel.js @@ -0,0 +1,365 @@ +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` + + `; +}; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js new file mode 100644 index 00000000..f82c2794 --- /dev/null +++ b/lib/public/js/components/sidebar.js @@ -0,0 +1,510 @@ +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 { 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) => String(agent?.identity?.emoji || "").trim(); + +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} + /> +
+`; +}; diff --git a/lib/public/js/components/summary-stat-card.js b/lib/public/js/components/summary-stat-card.js new file mode 100644 index 00000000..b58afe64 --- /dev/null +++ b/lib/public/js/components/summary-stat-card.js @@ -0,0 +1,17 @@ +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}
+
+`; diff --git a/lib/public/js/components/telegram-workspace.js b/lib/public/js/components/telegram-workspace.js deleted file mode 100644 index d2b94941..00000000 --- a/lib/public/js/components/telegram-workspace.js +++ /dev/null @@ -1,1365 +0,0 @@ -import { h } from "https://esm.sh/preact"; -import { useState, useEffect } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; -import { Badge } from "./badge.js"; -import { showToast } from "./toast.js"; -import { ActionButton } from "./action-button.js"; - -const html = htm.bind(h); - -const authFetch = async (url, opts = {}) => { - const res = await fetch(url, opts); - if (res.status === 401) { - window.location.href = "/setup"; - throw new Error("Unauthorized"); - } - return res; -}; -const api = { - verifyBot: async () => { - const res = await authFetch("/api/telegram/bot"); - return res.json(); - }, - workspace: async () => { - const res = await authFetch("/api/telegram/workspace"); - return res.json(); - }, - resetWorkspace: async () => { - const res = await authFetch("/api/telegram/workspace/reset", { - method: "POST", - }); - return res.json(); - }, - verifyGroup: async (groupId) => { - const res = await authFetch("/api/telegram/groups/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ groupId }), - }); - return res.json(); - }, - listTopics: async (groupId) => { - const res = await authFetch( - `/api/telegram/groups/${encodeURIComponent(groupId)}/topics`, - ); - return res.json(); - }, - createTopicsBulk: async (groupId, topics) => { - const res = await authFetch( - `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/bulk`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ topics }), - }, - ); - return res.json(); - }, - deleteTopic: async (groupId, topicId) => { - const res = await authFetch( - `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${topicId}`, - { method: "DELETE" }, - ); - return res.json(); - }, - updateTopic: async (groupId, topicId, payload) => { - const res = await authFetch( - `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${encodeURIComponent(topicId)}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.json(); - }, - configureGroup: async (groupId, payload) => { - const res = await authFetch( - `/api/telegram/groups/${encodeURIComponent(groupId)}/configure`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.json(); - }, -}; - -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" }, -]; - -const kTelegramWorkspaceStorageKey = "telegram-workspace-state-v1"; -const kTelegramWorkspaceCacheKey = "telegram-workspace-cache-v1"; -const loadTelegramWorkspaceState = () => { - try { - const raw = window.localStorage.getItem(kTelegramWorkspaceStorageKey); - if (!raw) return {}; - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } -}; -const loadTelegramWorkspaceCache = () => { - try { - const raw = window.localStorage.getItem(kTelegramWorkspaceCacheKey); - 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 = (data) => { - try { - window.localStorage.setItem( - kTelegramWorkspaceCacheKey, - JSON.stringify({ cachedAt: Date.now(), data }), - ); - } catch {} -}; - -const StepIndicator = ({ currentStep }) => html` -
- ${kSteps.map( - (s, i) => html` -
- `, - )} -
-`; - -const BackButton = ({ onBack }) => html` - -`; - -// Step 1: Verify Bot -const VerifyBotStep = ({ botInfo, setBotInfo, onNext }) => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const verify = async () => { - setLoading(true); - setError(null); - try { - const data = await api.verifyBot(); - if (!data.ok) throw new Error(data.error); - setBotInfo(data.bot); - } catch (e) { - setError(e.message); - } - setLoading(false); - }; - - useEffect(() => { - if (!botInfo) verify(); - }, []); - - return html` -
-

Verify Bot Setup

- - ${botInfo && - html` -
-
- @${botInfo.username} - <${Badge} tone="success">Connected -
-

${botInfo.first_name}

-
- `} - ${error && - html` -
-

${error}

-
- `} - ${!botInfo && - !loading && - !error && - html`

Checking bot token...

`} - -
-

- Before continuing, configure BotFather: -

-
    -
  1. - Open @BotFather in Telegram -
  2. -
  3. - Send /mybots and - select your bot -
  4. -
  5. - Go to Bot Settings > - Group Privacy -
  6. -
  7. Turn it OFF
  8. -
-
- -
-
- -
-
- `; -}; - -// Step 2: Create Group -const CreateGroupStep = ({ onNext, onBack }) => html` -
-

Create a Telegram Group

- -
-

Create the group

-
    -
  1. - Open Telegram and create a - new group -
  2. -
  3. - Search for and add your bot as a - member -
  4. -
  5. - Hit Next, give the group a name - (e.g. "My Workspace"), and create it -
  6. -
-
- -
-

Enable topics

-
    -
  1. Tap the group name at the top to open settings
  2. -
  3. - Tap Edit (pencil icon), scroll to - Topics, toggle it - ON -
  4. -
-
- -
-

Make the bot an admin

-
    -
  1. Go to Members, tap your bot
  2. -
  3. - Promote it to Admin -
  4. -
  5. - Make sure - Manage Topics - permission is enabled -
  6. -
-
- -

- Once all three steps are done, continue to verify the setup. -

- -
- - -
-
-`; - -// Step 3: Add Bot to Group / Verify Group -const AddBotStep = ({ - groupId, - setGroupId, - groupInfo, - setGroupInfo, - userId, - setUserId, - verifyGroupError, - setVerifyGroupError, - onNext, - onBack, -}) => { - const [input, setInput] = useState(groupId || ""); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const verifyWarnings = groupInfo - ? [ - ...(!groupInfo.chat?.isForum - ? ["Topics are OFF. Enable Topics in Telegram group settings."] - : []), - ...(!groupInfo.bot?.isAdmin - ? ["Bot is not an admin. Promote it to admin in group members."] - : []), - ...(!groupInfo.bot?.canManageTopics - ? [ - "Bot is missing Manage Topics permission. Enable it in admin permissions.", - ] - : []), - ] - : []; - - const verify = async () => { - const id = input.trim(); - if (!id) return; - setLoading(true); - setVerifyGroupError(null); - try { - const data = await api.verifyGroup(id); - if (!data.ok) throw new Error(data.error); - setGroupId(id); - setGroupInfo(data); - if (!String(userId || "").trim() && data.suggestedUserId) { - setUserId(String(data.suggestedUserId)); - } - } catch (e) { - setVerifyGroupError(e.message); - setGroupInfo(null); - } - setLoading(false); - }; - const canContinue = !!( - groupInfo && - groupInfo.chat?.isForum && - groupInfo.bot?.isAdmin && - groupInfo.bot?.canManageTopics - ); - const continueWithConfig = async () => { - if (!canContinue || saving) return; - setVerifyGroupError(null); - setSaving(true); - try { - const userIdValue = String(userId || "").trim(); - const data = await api.configureGroup(groupId, { - ...(userIdValue ? { userId: userIdValue } : {}), - groupName: groupInfo?.chat?.title || groupId, - requireMention: false, - }); - if (!data?.ok) - throw new Error(data?.error || "Failed to configure Telegram group"); - if (data.userId) setUserId(String(data.userId)); - onNext(); - } catch (e) { - setVerifyGroupError(e.message); - } - setSaving(false); - }; - - return html` -
-

Verify Group

- -
-

To get your group chat ID:

-
    -
  1. - Invite @myidbot to your group -
  2. -
  3. - Send /getgroupid -
  4. -
  5. - Copy the ID (starts with - -100) -
  6. -
-
- -
- setInput(e.target.value)} - placeholder="-100XXXXXXXXXX" - class="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500" - /> - <${ActionButton} - onClick=${verify} - disabled=${!input.trim() || loading} - loading=${loading} - tone="secondary" - size="md" - idleLabel="Verify" - loadingMode="inline" - className="rounded-lg" - /> -
- - ${verifyGroupError && - html` -
-

${verifyGroupError}

-
- `} - ${groupInfo && - html` -
-
- ${groupInfo.chat.title} - <${Badge} tone="success">Verified -
-
- Topics: ${groupInfo.chat.isForum ? "ON" : "OFF"} - Bot: ${groupInfo.bot.status} -
-
- `} - ${groupInfo && - verifyWarnings.length === 0 && - html` -
-

Your Telegram User ID

- setUserId(e.target.value)} - placeholder="e.g. 123456789" - class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500" - /> -

- Auto-filled from Telegram admins. Edit if needed. -

-
- `} - ${verifyWarnings.length > 0 && - html` -
-

- Fix these before continuing: -

-
    - ${verifyWarnings.map((message) => html`
  • ${message}
  • `)} -
-

Once fixed, hit Verify again.

-
- `} - -
- - -
-
- `; -}; - -// Step 4: Create Topics -const TopicsStep = ({ groupId, topics, setTopics, onNext, onBack }) => { - const [newTopicName, setNewTopicName] = useState(""); - const [newTopicInstructions, setNewTopicInstructions] = useState(""); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - const [deleting, setDeleting] = useState(null); - - const loadTopics = async () => { - const data = await api.listTopics(groupId); - if (data.ok) setTopics(data.topics); - }; - - useEffect(() => { - loadTopics(); - }, [groupId]); - - const createSingle = async () => { - const name = newTopicName.trim(); - const systemInstructions = newTopicInstructions.trim(); - if (!name) return; - setCreating(true); - setError(null); - try { - const data = await api.createTopicsBulk(groupId, [ - { name, ...(systemInstructions ? { systemInstructions } : {}) }, - ]); - 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(""); - 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); - 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 topicEntries = Object.entries(topics || {}); - - return html` -
-

Create Topics

- - ${topicEntries.length > 0 && - html` -
- - - - - - - - - ${topicEntries.map( - ([id, t]) => html` - - - - - - `, - )} - -
- Topic - - Thread ID - -
${t.name}${id} - -
-
- `} - -
- -
-
- setNewTopicName(e.target.value)} - onKeyDown=${(e) => { - if (e.key === "Enter") createSingle(); - }} - placeholder="Topic name" - class="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500" - /> -
-