diff --git a/.github/workflows/alpha-macos-aarch64.yml b/.github/workflows/alpha-macos-aarch64.yml index 2f6997a3..b84d4391 100644 --- a/.github/workflows/alpha-macos-aarch64.yml +++ b/.github/workflows/alpha-macos-aarch64.yml @@ -1,4 +1,17 @@ -name: Alpha Desktop Artifact (macOS arm64) +name: Alpha Channel (macOS arm64) + +# Every merge to `dev` publishes a fresh macOS arm64 build to the OpenWork +# alpha release channel. +# +# The alpha channel is macOS-only today. It lives as a rolling GitHub +# release under the fixed tag `alpha-macos-latest` so the Tauri updater +# manifest URL stays stable while the underlying artifact gets replaced on +# every run. +# +# See: +# - apps/app/src/app/lib/release-channels.ts (updater endpoint URLs) +# - ARCHITECTURE.md#release-channels +# - .github/workflows/release-macos-aarch64.yml (stable channel) on: push: @@ -7,20 +20,26 @@ on: workflow_dispatch: permissions: - contents: read + contents: write concurrency: group: alpha-macos-aarch64-${{ github.ref }} cancel-in-progress: true jobs: - build-alpha-macos-aarch64: - name: Build alpha artifact (aarch64-apple-darwin) + publish-alpha-macos-aarch64: + name: Build + Publish alpha (aarch64-apple-darwin) runs-on: macos-14 timeout-minutes: 180 env: OPENCODE_GITHUB_REPO: ${{ vars.OPENCODE_GITHUB_REPO || 'anomalyco/opencode' }} + ALPHA_RELEASE_TAG: alpha-macos-latest + ALPHA_RELEASE_NAME: OpenWork Alpha (macOS arm64) + # Apple signing + notarization are required so alpha bundles install + # and launch without Gatekeeper friction. Alpha builds are served + # from GitHub Releases like stable, just from a different tag. + MACOS_NOTARIZE: ${{ vars.MACOS_NOTARIZE || 'true' }} steps: - name: Checkout @@ -72,9 +91,68 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline - - name: Create CI Tauri config (no updater artifacts) + - name: Resolve alpha version + id: alpha-version + shell: bash + env: + GITHUB_RUN_NUMBER: ${{ github.run_number }} + GITHUB_SHA: ${{ github.sha }} run: | - node -e "const fs=require('fs'); const configPath='apps/desktop/src-tauri/tauri.conf.json'; const ciPath='apps/desktop/src-tauri/tauri.conf.alpha.json'; const config=JSON.parse(fs.readFileSync(configPath,'utf8')); config.bundle={...config.bundle, createUpdaterArtifacts:false}; fs.writeFileSync(ciPath, JSON.stringify(config, null, 2));" + set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require("node:fs"); + const path = "apps/desktop/src-tauri/tauri.conf.json"; + const raw = JSON.parse(fs.readFileSync(path, "utf8")); + const current = String(raw.version || "").trim(); + const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/); + if (!match) { + throw new Error(`Unsupported version in ${path}: ${current}`); + } + const [, major, minor, patch] = match; + // Alpha builds advertise the *next* patch version so semver + // comparison makes the alpha newer than the current stable + // (e.g. stable 0.11.207 < alpha 0.11.208-alpha.). Once + // stable 0.11.208 ships, its semver beats the alpha prerelease + // tag and alpha users cleanly migrate forward. + const nextPatch = Number(patch) + 1; + const run = process.env.GITHUB_RUN_NUMBER || "0"; + const sha = (process.env.GITHUB_SHA || "").slice(0, 7) || "local"; + const alpha = `${major}.${minor}.${nextPatch}-alpha.${run}+${sha}`; + console.log(`alpha_version=${alpha}`); + console.log(`base_version=${major}.${minor}.${nextPatch}`); + NODE + + - name: Write alpha Tauri config override + shell: bash + env: + ALPHA_VERSION: ${{ steps.alpha-version.outputs.alpha_version }} + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + const base = "apps/desktop/src-tauri/tauri.conf.json"; + const out = "apps/desktop/src-tauri/tauri.conf.alpha.json"; + const config = JSON.parse(fs.readFileSync(base, "utf8")); + + config.version = process.env.ALPHA_VERSION; + + // Alpha builds must advertise updater artifacts so the + // Tauri updater receives a `.app.tar.gz` + `.sig` pair. + config.bundle = { ...(config.bundle || {}), createUpdaterArtifacts: true }; + + // Point this build's updater at the alpha channel's rolling + // manifest. The stable endpoint stays in the base config for + // everyone else. + config.plugins = config.plugins || {}; + config.plugins.updater = { + ...(config.plugins.updater || {}), + endpoints: [ + "https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json", + ], + }; + + fs.writeFileSync(out, `${JSON.stringify(config, null, 2)}\n`); + NODE - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -122,16 +200,113 @@ jobs: cp "$extract_dir/opencode" "apps/desktop/src-tauri/sidecars/opencode-aarch64-apple-darwin" chmod 755 "apps/desktop/src-tauri/sidecars/opencode-aarch64-apple-darwin" - - name: Build alpha desktop app - run: pnpm --filter @openwork/desktop exec tauri build --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app + - name: Clear previous alpha release (rolling channel) + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Keep a single rolling release under ALPHA_RELEASE_TAG. Delete + # whatever exists so tauri-action can recreate it fresh with + # this run's artifacts, and users on the alpha channel always + # resolve to the freshest latest.json. + gh release delete "$ALPHA_RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --cleanup-tag \ + --yes || true - - name: Upload alpha artifact bundle - uses: actions/upload-artifact@v7 + - name: Write notary API key + if: env.MACOS_NOTARIZE == 'true' + env: + APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} + run: | + set -euo pipefail + + NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" + printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$NOTARY_KEY_PATH" + chmod 600 "$NOTARY_KEY_PATH" + + echo "NOTARY_KEY_PATH=$NOTARY_KEY_PATH" >> "$GITHUB_ENV" + + - name: Build + upload alpha (notarized) + if: env.MACOS_NOTARIZE == 'true' + uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Tauri updater signing — same minisign keypair as stable so + # an installed stable build can update into alpha and back. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + + # macOS signing + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + + # macOS notarization (App Store Connect API key) + APPLE_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} + APPLE_API_KEY_PATH: ${{ env.NOTARY_KEY_PATH }} with: - name: openwork-alpha-macos-aarch64-${{ github.sha }} - path: | - apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg - apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz - apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app.tar.gz.sig - if-no-files-found: error - retention-days: 14 + tagName: ${{ env.ALPHA_RELEASE_TAG }} + releaseName: ${{ env.ALPHA_RELEASE_NAME }} + releaseBody: | + Rolling alpha build for OpenWork (macOS arm64). + Every merge to `dev` replaces the artifacts attached to this release. + Subscribe from Settings → Updates → Release channel → Alpha. + releaseDraft: false + prerelease: true + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app + retryAttempts: 3 + uploadUpdaterJson: false + releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] + + - name: Build + upload alpha (unsigned fallback) + if: env.MACOS_NOTARIZE != 'true' + uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tagName: ${{ env.ALPHA_RELEASE_TAG }} + releaseName: ${{ env.ALPHA_RELEASE_NAME }} + releaseBody: | + Rolling alpha build for OpenWork (macOS arm64). + Unsigned build (MACOS_NOTARIZE disabled). macOS Gatekeeper will + require a manual open-on-first-launch. Subscribe from Settings → + Updates → Release channel → Alpha. + releaseDraft: false + prerelease: true + projectPath: apps/desktop + tauriScript: pnpm exec tauri -vvv + args: --config src-tauri/tauri.conf.alpha.json --target aarch64-apple-darwin --bundles dmg,app + retryAttempts: 3 + uploadUpdaterJson: false + releaseAssetNamePattern: openwork-desktop-[platform]-[arch][ext] + + - name: Generate alpha latest.json + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/release/generate-latest-json.mjs \ + --tag "$ALPHA_RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --output "$RUNNER_TEMP/alpha-latest.json" + + - name: Upload alpha latest.json + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$ALPHA_RELEASE_TAG" \ + "$RUNNER_TEMP/alpha-latest.json#latest.json" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber diff --git a/.opencode/commands/browser-setup.md b/.opencode/commands/browser-setup.md index fa03f508..240878a9 100644 --- a/.opencode/commands/browser-setup.md +++ b/.opencode/commands/browser-setup.md @@ -5,5 +5,5 @@ description: Guide user through Chrome browser automation setup Help the user set up browser automation. -Use the `browser-setup-devtools` skill and follow it strictly (Chrome DevTools MCP first, extension only as fallback). +Use the `browser-setup-devtools` skill and follow it strictly (Chrome DevTools MCP only). Keep the user prompt minimal and let the skill drive the setup dance. diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 00000000..86bbf564 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.3.17" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.17", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz", + "integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.17", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.96", + "@opentui/solid": ">=0.1.96" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.17", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz", + "integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.opencode/skills/browser-setup-devtools/SKILL.md b/.opencode/skills/browser-setup-devtools/SKILL.md index 4219072c..4daca7d7 100644 --- a/.opencode/skills/browser-setup-devtools/SKILL.md +++ b/.opencode/skills/browser-setup-devtools/SKILL.md @@ -1,14 +1,14 @@ --- name: browser-setup-devtools -description: Guide users through browser automation setup using Chrome DevTools MCP as the primary path and the OpenCode browser extension as a fallback. Use when the user asks to set up browser automation, Chrome DevTools MCP, browser MCP, browser extension, or runs the browser-setup command. +description: Guide users through browser automation setup using Chrome DevTools MCP only. Use when the user asks to set up browser automation, Chrome DevTools MCP, browser MCP, or runs the browser-setup command. --- -# Browser automation setup (DevTools MCP first) +# Browser automation setup (Chrome DevTools MCP) ## Principles - Keep prompts minimal; do as much as possible with tools and commands. -- Always attempt Chrome DevTools MCP first; only fall back to the browser extension when DevTools MCP cannot be used. +- Use Chrome DevTools MCP only. ## Workflow @@ -29,16 +29,6 @@ description: Guide users through browser automation setup using Chrome DevTools 5. If DevTools MCP is ready: - Offer a first task ("Let's try opening a webpage"). - If yes, use `chrome-devtools_navigate_page` or `chrome-devtools_new_page` to open the URL and confirm completion. -6. Fallback only if DevTools MCP cannot be used: - - Check availability with `browser_version` or `browser_status`. - - If missing, run `npx @different-ai/opencode-browser install` yourself. - - Open the Extensions page yourself when possible: - - macOS: `open -a "Google Chrome" "chrome://extensions"` - - Windows: `start chrome://extensions` - - Linux: `xdg-open "chrome://extensions"` - - Tell the user to enable Developer mode, click "Load unpacked", and select `~/.opencode-browser/extension`, then pin the extension. - - Re-check availability with `browser_version`. - - Offer a first task and use `browser_open_tab`. ## Response rules diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 02e2f4f7..d573baf8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,6 +85,28 @@ Tauri or other native shell behavior remains the fallback or shell boundary for: If an agent needs one of the server-owned behaviors above and only a Tauri path exists, treat that as an architecture gap to close rather than a parallel capability surface to preserve. +## Release channels + +OpenWork desktop ships through two release channels: + +- **Stable** (default, all platforms): versioned builds produced by the `Release App` workflow. Each tag `vX.Y.Z` publishes signed, notarized bundles plus a `latest.json` updater manifest at `https://github.com/different-ai/openwork/releases/latest/download/latest.json`. +- **Alpha** (macOS arm64 only, rolling): every merge to `dev` publishes a signed, notarized build to the rolling GitHub release tagged `alpha-macos-latest`. The alpha updater manifest lives at a stable URL: `https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json`. + +Guidelines: + +- The alpha channel is an opt-in preference (`LocalPreferences.releaseChannel`). The toggle is rendered only when `isTauriRuntime()` and `isMacPlatform()` both resolve true; other platforms silently fall back to stable even if the stored preference says `"alpha"`. +- Alpha builds advertise the next patch version plus an `-alpha.+` prerelease suffix. That keeps semver ordering `stable < alpha.1 < alpha.2 < next stable` so alpha users migrate forward cleanly when the next stable ships. +- Alpha and stable share the same Tauri updater signing keypair so an installed stable can upgrade into alpha and vice versa without re-installing manually. +- Apple signing and notarization are required on both channels; the `MACOS_NOTARIZE` repo variable gates the signed path in `alpha-macos-aarch64.yml`. +- The alpha workflow is the source of truth for the alpha channel's CI contract. Treat `.github/workflows/alpha-macos-aarch64.yml`, `apps/app/src/app/lib/release-channels.ts`, and this document as one coupled unit. + +Code references: + +- Workflow: `.github/workflows/alpha-macos-aarch64.yml` +- Endpoint resolution: `apps/app/src/app/lib/release-channels.ts` +- Preference plumbing: `apps/app/src/react-app/kernel/local-provider.tsx`, `apps/app/src/react-app/domains/settings/pages/updates-view.tsx` +- Stable workflow (reference): `.github/workflows/release-macos-aarch64.yml` + ## Reload-required flow OpenWork uses a single reload-required flow for changes that only take effect when OpenCode restarts. diff --git a/apps/app/index.html b/apps/app/index.html index 38f9ff91..7ac4f564 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -26,6 +26,6 @@
- + diff --git a/apps/app/package.json b/apps/app/package.json index f459e593..a9f7764f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -46,11 +46,8 @@ "@openwork/types": "workspace:*", "@openwork/ui": "workspace:*", "@radix-ui/colors": "^3.0.0", - "@solid-primitives/event-bus": "^1.1.2", - "@solid-primitives/storage": "^4.3.3", - "@solidjs/router": "^0.15.4", "@tanstack/react-query": "^5.90.3", - "@tanstack/solid-virtual": "^3.13.19", + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-dialog": "~2.6.0", @@ -61,27 +58,25 @@ "ai": "^6.0.146", "fuzzysort": "^3.1.0", "jsonc-parser": "^3.2.1", - "lucide-solid": "^0.562.0", "lexical": "^0.35.0", + "lucide-react": "^0.577.0", "marked": "^17.0.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", + "react-router-dom": "^7.14.1", "remark-gfm": "^4.0.1", - "solid-js": "^1.9.0", - "streamdown": "^2.5.0" + "streamdown": "^2.5.0", + "zustand": "^5.0.12" }, "devDependencies": { - "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/vite": "^4.1.18", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", - "solid-devtools": "^0.34.5", "tailwindcss": "^4.1.18", "typescript": "^5.6.3", - "vite": "^6.0.1", - "vite-plugin-solid": "^2.11.0" + "vite": "^6.0.1" }, "packageManager": "pnpm@10.27.0" } diff --git a/apps/app/src/app/app-settings/authorized-folders-panel.tsx b/apps/app/src/app/app-settings/authorized-folders-panel.tsx deleted file mode 100644 index 66b1554f..00000000 --- a/apps/app/src/app/app-settings/authorized-folders-panel.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { - For, - Show, - createEffect, - createMemo, - createSignal, - onCleanup, -} from "solid-js"; - -import { Folder, FolderLock, FolderSearch, X } from "lucide-solid"; - -import { t } from "../../i18n"; -import Button from "../components/button"; -import type { - OpenworkServerCapabilities, - OpenworkServerClient, - OpenworkServerStatus, -} from "../lib/openwork-server"; -import { pickDirectory } from "../lib/tauri"; -import { - isTauriRuntime, - normalizeDirectoryQueryPath, - safeStringify, -} from "../utils"; - -type AuthorizedFoldersPanelProps = { - openworkServerClient: OpenworkServerClient | null; - openworkServerStatus: OpenworkServerStatus; - openworkServerCapabilities: OpenworkServerCapabilities | null; - runtimeWorkspaceId: string | null; - selectedWorkspaceRoot: string; - activeWorkspaceType: "local" | "remote"; - onConfigUpdated: () => void; -}; - -const panelClass = "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6"; -const softPanelClass = "rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4"; - -const ensureRecord = (value: unknown): Record => { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - return value as Record; -}; - -const normalizeAuthorizedFolderPath = (input: string | null | undefined) => { - const trimmed = (input ?? "").trim(); - if (!trimmed) return ""; - const withoutWildcard = trimmed.replace(/[\\/]\*+$/, ""); - return normalizeDirectoryQueryPath(withoutWildcard); -}; - -const authorizedFolderToExternalDirectoryKey = (folder: string) => { - const normalized = normalizeAuthorizedFolderPath(folder); - if (!normalized) return ""; - return normalized === "/" ? "/*" : `${normalized}/*`; -}; - -const externalDirectoryKeyToAuthorizedFolder = (key: string, value: unknown) => { - if (value !== "allow") return null; - const trimmed = key.trim(); - if (!trimmed) return null; - if (trimmed === "/*") return "/"; - if (!trimmed.endsWith("/*")) return null; - return normalizeAuthorizedFolderPath(trimmed.slice(0, -2)); -}; - -const readAuthorizedFoldersFromConfig = (opencodeConfig: Record) => { - const permission = ensureRecord(opencodeConfig.permission); - const externalDirectory = ensureRecord(permission.external_directory); - const folders: string[] = []; - const hiddenEntries: Record = {}; - const seen = new Set(); - - for (const [key, value] of Object.entries(externalDirectory)) { - const folder = externalDirectoryKeyToAuthorizedFolder(key, value); - if (!folder) { - hiddenEntries[key] = value; - continue; - } - if (seen.has(folder)) continue; - seen.add(folder); - folders.push(folder); - } - - return { folders, hiddenEntries }; -}; - -const buildAuthorizedFoldersStatus = (preservedCount: number, action?: string) => { - const preservedLabel = - preservedCount > 0 - ? preservedCount === 1 - ? t("context_panel.preserving_entry") - : t("context_panel.preserving_entries", undefined, { count: preservedCount }) - : null; - if (action && preservedLabel) return `${action} ${preservedLabel}`; - return action ?? preservedLabel; -}; - -const mergeAuthorizedFoldersIntoExternalDirectory = ( - folders: string[], - hiddenEntries: Record, -): Record | undefined => { - const next: Record = { ...hiddenEntries }; - for (const folder of folders) { - const key = authorizedFolderToExternalDirectoryKey(folder); - if (!key) continue; - next[key] = "allow"; - } - return Object.keys(next).length ? next : undefined; -}; - -export default function AuthorizedFoldersPanel(props: AuthorizedFoldersPanelProps) { - const [authorizedFolders, setAuthorizedFolders] = createSignal([]); - const [authorizedFolderDraft, setAuthorizedFolderDraft] = createSignal(""); - const [authorizedFoldersLoading, setAuthorizedFoldersLoading] = createSignal(false); - const [authorizedFoldersSaving, setAuthorizedFoldersSaving] = createSignal(false); - const [authorizedFoldersStatus, setAuthorizedFoldersStatus] = createSignal(null); - const [authorizedFoldersError, setAuthorizedFoldersError] = createSignal(null); - - const openworkServerReady = createMemo( - () => props.openworkServerStatus === "connected", - ); - const openworkServerWorkspaceReady = createMemo( - () => Boolean(props.runtimeWorkspaceId), - ); - const canReadConfig = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (props.openworkServerCapabilities?.config?.read ?? false), - ); - const canWriteConfig = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (props.openworkServerCapabilities?.config?.write ?? false), - ); - const authorizedFoldersHint = createMemo(() => { - if (!openworkServerReady()) return t("context_panel.server_disconnected"); - if (!openworkServerWorkspaceReady()) return t("context_panel.no_server_workspace"); - if (!canReadConfig()) { - return t("context_panel.config_access_unavailable"); - } - if (!canWriteConfig()) { - return t("context_panel.config_read_only"); - } - return null; - }); - const canPickAuthorizedFolder = createMemo( - () => isTauriRuntime() && canWriteConfig() && props.activeWorkspaceType === "local", - ); - const workspaceRootFolder = createMemo(() => props.selectedWorkspaceRoot.trim()); - const visibleAuthorizedFolders = createMemo(() => { - const root = workspaceRootFolder(); - return root ? [root, ...authorizedFolders()] : authorizedFolders(); - }); - - createEffect(() => { - const openworkClient = props.openworkServerClient; - const openworkWorkspaceId = props.runtimeWorkspaceId; - const readable = canReadConfig(); - - if (!openworkClient || !openworkWorkspaceId || !readable) { - setAuthorizedFolders([]); - setAuthorizedFolderDraft(""); - setAuthorizedFoldersLoading(false); - setAuthorizedFoldersSaving(false); - setAuthorizedFoldersStatus(null); - setAuthorizedFoldersError(null); - return; - } - - let cancelled = false; - setAuthorizedFolderDraft(""); - setAuthorizedFoldersLoading(true); - setAuthorizedFoldersError(null); - setAuthorizedFoldersStatus(null); - - const loadAuthorizedFolders = async () => { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - if (cancelled) return; - const next = readAuthorizedFoldersFromConfig(ensureRecord(config.opencode)); - setAuthorizedFolders(next.folders); - setAuthorizedFoldersStatus( - buildAuthorizedFoldersStatus(Object.keys(next.hiddenEntries).length), - ); - } catch (error) { - if (cancelled) return; - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFolders([]); - setAuthorizedFoldersError(message); - } finally { - if (!cancelled) { - setAuthorizedFoldersLoading(false); - } - } - }; - - void loadAuthorizedFolders(); - - onCleanup(() => { - cancelled = true; - }); - }); - - const persistAuthorizedFolders = async (nextFolders: string[]) => { - const openworkClient = props.openworkServerClient; - const openworkWorkspaceId = props.runtimeWorkspaceId; - if (!openworkClient || !openworkWorkspaceId || !canWriteConfig()) { - setAuthorizedFoldersError( - t("context_panel.writable_workspace_required"), - ); - return false; - } - - setAuthorizedFoldersSaving(true); - setAuthorizedFoldersError(null); - setAuthorizedFoldersStatus(t("context_panel.saving_folders")); - - try { - const currentConfig = await openworkClient.getConfig(openworkWorkspaceId); - const currentAuthorizedFolders = readAuthorizedFoldersFromConfig( - ensureRecord(currentConfig.opencode), - ); - const nextExternalDirectory = mergeAuthorizedFoldersIntoExternalDirectory( - nextFolders, - currentAuthorizedFolders.hiddenEntries, - ); - - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { - permission: { - external_directory: nextExternalDirectory, - }, - }, - }); - setAuthorizedFolders(nextFolders); - setAuthorizedFoldersStatus( - buildAuthorizedFoldersStatus( - Object.keys(currentAuthorizedFolders.hiddenEntries).length, - t("context_panel.folders_updated"), - ), - ); - props.onConfigUpdated(); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFoldersError(message); - setAuthorizedFoldersStatus(null); - return false; - } finally { - setAuthorizedFoldersSaving(false); - } - }; - - const addAuthorizedFolder = async () => { - const normalized = normalizeAuthorizedFolderPath(authorizedFolderDraft()); - const workspaceRoot = normalizeAuthorizedFolderPath(workspaceRootFolder()); - if (!normalized) return; - if (workspaceRoot && normalized === workspaceRoot) { - setAuthorizedFolderDraft(""); - setAuthorizedFoldersStatus(t("context_panel.workspace_root_available")); - setAuthorizedFoldersError(null); - return; - } - if (authorizedFolders().includes(normalized)) { - setAuthorizedFolderDraft(""); - setAuthorizedFoldersStatus(t("context_panel.folder_already_authorized")); - setAuthorizedFoldersError(null); - return; - } - - const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); - if (ok) { - setAuthorizedFolderDraft(""); - } - }; - - const removeAuthorizedFolder = async (folder: string) => { - const nextFolders = authorizedFolders().filter((entry) => entry !== folder); - await persistAuthorizedFolders(nextFolders); - }; - - const pickAuthorizedFolder = async () => { - if (!isTauriRuntime()) return; - try { - const selection = await pickDirectory({ - title: t("onboarding.authorize_folder"), - }); - const folder = - typeof selection === "string" - ? selection - : Array.isArray(selection) - ? selection[0] - : null; - const normalized = normalizeAuthorizedFolderPath(folder); - const workspaceRoot = normalizeAuthorizedFolderPath(workspaceRootFolder()); - if (!normalized) return; - setAuthorizedFolderDraft(normalized); - if (workspaceRoot && normalized === workspaceRoot) { - setAuthorizedFolderDraft(""); - setAuthorizedFoldersStatus(t("context_panel.workspace_root_available")); - setAuthorizedFoldersError(null); - return; - } - if (authorizedFolders().includes(normalized)) { - setAuthorizedFoldersStatus(t("context_panel.folder_already_authorized")); - setAuthorizedFoldersError(null); - return; - } - const ok = await persistAuthorizedFolders([...authorizedFolders(), normalized]); - if (ok) { - setAuthorizedFolderDraft(""); - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - setAuthorizedFoldersError(message); - } - }; - - return ( -
-
-
- - {t("context_panel.authorized_folders")} -
-
- {t("context_panel.authorized_folders_desc")} -
-
- - - {authorizedFoldersHint() ?? - t("context_panel.authorized_folders_no_access")} -
- } - > -
- - {(hint) => ( -
- {hint()} -
- )} -
- - 0} - fallback={ -
-
- -
-
{t("context_panel.no_external_folders")}
-
- {t("context_panel.add_folder_hint")} -
-
- } - > -
- - {(folder) => { - const isWorkspaceRoot = folder === workspaceRootFolder(); - const folderName = folder.split(/[\/\\]/).filter(Boolean).pop() || folder; - return ( -
-
-
- -
-
-
- {folderName} - - - {t("context_panel.workspace_root_badge")} - - -
- {folder} -
-
- - {t("context_panel.always_available")} - - } - > - - -
- ); - }} -
-
-
- - - {(status) => ( -
- {status()} -
- )} -
- - {(error) => ( -
- {error()} -
- )} -
- -
{ - event.preventDefault(); - void addAuthorizedFolder(); - }} - > -
- setAuthorizedFolderDraft(event.currentTarget.value)} - onPaste={(event) => { - event.preventDefault(); - }} - placeholder={t("context_panel.input_placeholder")} - disabled={ - authorizedFoldersLoading() || - authorizedFoldersSaving() || - !canWriteConfig() - } - /> -
- - - - - - -
-
- - - ); -} diff --git a/apps/app/src/app/app-settings/feature-flags-preferences.ts b/apps/app/src/app/app-settings/feature-flags-preferences.ts deleted file mode 100644 index 7e5ddda8..00000000 --- a/apps/app/src/app/app-settings/feature-flags-preferences.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useLocal } from "../context/local"; - -export function useFeatureFlagsPreferences() { - const { prefs, setPrefs } = useLocal(); - - const microsandboxCreateSandboxEnabled = () => - prefs.featureFlags?.microsandboxCreateSandbox === true; - - const toggleMicrosandboxCreateSandbox = () => { - setPrefs("featureFlags", "microsandboxCreateSandbox", (current) => !current); - }; - - return { - microsandboxCreateSandboxEnabled, - toggleMicrosandboxCreateSandbox, - }; -} diff --git a/apps/app/src/app/app-settings/model-controls-provider.tsx b/apps/app/src/app/app-settings/model-controls-provider.tsx deleted file mode 100644 index b55c76ac..00000000 --- a/apps/app/src/app/app-settings/model-controls-provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ModelControlsStore } from "./model-controls-store"; - -const ModelControlsContext = createContext(); - -export function ModelControlsProvider(props: ParentProps<{ store: ModelControlsStore }>) { - return ( - - {props.children} - - ); -} - -export function useModelControls() { - const context = useContext(ModelControlsContext); - if (!context) { - throw new Error("useModelControls must be used within a ModelControlsProvider"); - } - return context; -} diff --git a/apps/app/src/app/app-settings/model-controls-store.ts b/apps/app/src/app/app-settings/model-controls-store.ts deleted file mode 100644 index 9d76247d..00000000 --- a/apps/app/src/app/app-settings/model-controls-store.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Accessor } from "solid-js"; - -export type ModelBehaviorOption = { value: string | null; label: string }; - -export type ModelControlsStore = ReturnType; - -export function createModelControlsStore(options: { - selectedSessionModelLabel: Accessor; - openSessionModelPicker: (options?: { returnFocusTarget?: "none" | "composer" }) => void; - sessionModelVariantLabel: Accessor; - sessionModelVariant: Accessor; - sessionModelBehaviorOptions: Accessor; - setSessionModelVariant: (value: string | null) => void; - defaultModelLabel: Accessor; - defaultModelRef: Accessor; - openDefaultModelPicker: () => void; - autoCompactContext: Accessor; - toggleAutoCompactContext: () => void; - autoCompactContextBusy: Accessor; - defaultModelVariantLabel: Accessor; - editDefaultModelVariant: () => void; -}) { - return options; -} diff --git a/apps/app/src/app/app-settings/session-display-preferences.ts b/apps/app/src/app/app-settings/session-display-preferences.ts deleted file mode 100644 index ad17360a..00000000 --- a/apps/app/src/app/app-settings/session-display-preferences.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useLocal } from "../context/local"; - -type BooleanUpdater = boolean | ((current: boolean) => boolean); - -export function useSessionDisplayPreferences() { - const { prefs, setPrefs } = useLocal(); - - const showThinking = () => prefs.showThinking; - - const setShowThinking = (value: BooleanUpdater) => { - setPrefs("showThinking", (current) => - typeof value === "function" ? value(current) : value, - ); - }; - - const toggleShowThinking = () => { - setShowThinking((current) => !current); - }; - - const resetSessionDisplayPreferences = () => { - setShowThinking(false); - }; - - return { - showThinking, - setShowThinking, - toggleShowThinking, - resetSessionDisplayPreferences, - }; -} diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx deleted file mode 100644 index 715836b4..00000000 --- a/apps/app/src/app/app.tsx +++ /dev/null @@ -1,2931 +0,0 @@ -import { - Match, - Show, - Switch, - createEffect, - createMemo, - createSignal, - onCleanup, - onMount, - untrack, -} from "solid-js"; - -import { useLocation, useNavigate } from "@solidjs/router"; - -import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWebview } from "@tauri-apps/api/webview"; -import ModelPickerModal from "./components/model-picker-modal"; -import ConfirmModal from "./components/confirm-modal"; -import ResetModal from "./components/reset-modal"; -import SkillDestinationModal from "./bundles/skill-destination-modal"; -import BundleImportModal from "./bundles/import-modal"; -import BundleStartModal from "./bundles/start-modal"; -import { useDenAuth } from "./cloud/den-auth-provider"; -import { useDesktopConfig } from "./cloud/desktop-config-provider"; -import { - isDesktopProviderBlocked, - runDesktopAppRestrictionSyncEffects, -} from "./cloud/desktop-app-restrictions"; -import RestrictionNoticeModal from "./components/restriction-notice-modal"; -import ForcedSigninPage from "./cloud/forced-signin-page"; -import RenameWorkspaceModal from "./components/rename-workspace-modal"; -import ConnectionsModals from "./connections/modals"; -import { OpenworkServerProvider } from "./connections/openwork-server-provider"; -import { createOpenworkServerStore } from "./connections/openwork-server-store"; -import { ConnectionsProvider } from "./connections/provider"; -import { ExtensionsProvider } from "./extensions/provider"; -import { AutomationsProvider } from "./automations/provider"; -import { SessionActionsProvider } from "./session/actions-provider"; -import { createSessionActionsStore } from "./session/actions-store"; -import { createDeepLinksController } from "./shell/deep-links"; -import SettingsShell from "./shell/settings-shell"; -import TopRightNotifications from "./shell/top-right-notifications"; -import { createStatusToastsStore, StatusToastsProvider } from "./shell/status-toasts"; -import { - CreateRemoteWorkspaceModal, - CreateWorkspaceModal, -} from "./workspace"; -import SessionView from "./pages/session"; -import { clearDevLogs } from "./lib/dev-log"; -import { clearPerfLogs } from "./lib/perf-log"; -import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail } from "./lib/deep-link-bridge"; -import { - HIDE_TITLEBAR_PREF_KEY, - SUGGESTED_PLUGINS, -} from "./constants"; -import { readDenBootstrapConfig } from "./lib/den"; -import type { - Client, - StartupPreference, - EngineRuntime, - OnboardingStep, - ReloadReason, - ReloadTrigger, - SettingsTab, - View, - WorkspaceDisplay, - WorkspaceSessionGroup, - ProviderListItem, - OpencodeConnectStatus, -} from "./types"; -import { - clearStartupPreference, - deriveArtifacts, - deriveWorkingFiles, - isTauriRuntime, - normalizeDirectoryPath, -} from "./utils"; -import { currentLocale, setLocale, t } from "../i18n"; -import { - isWindowsPlatform, - lastUserModelFromMessages, - readStartupPreference, - safeStringify, -} from "./utils"; -import { - applyThemeMode, - getInitialThemeMode, - persistThemeMode, - subscribeToSystemTheme, - type ThemeMode, -} from "./theme"; -import { createSystemState } from "./system-state"; -import { createSessionStore } from "./context/session"; -import { - createModelConfigStore, -} from "./context/model-config"; -import { createProvidersStore } from "./context/providers"; -import { ModelControlsProvider } from "./app-settings/model-controls-provider"; -import { createModelControlsStore } from "./app-settings/model-controls-store"; -import { useFeatureFlagsPreferences } from "./app-settings/feature-flags-preferences"; -import { useSessionDisplayPreferences } from "./app-settings/session-display-preferences"; -import { - shouldRedirectMissingSessionAfterScopedLoad, -} from "./lib/session-scope"; -import { createExtensionsStore } from "./context/extensions"; -import { createConnectionsStore } from "./connections/store"; -import { createAutomationsStore } from "./context/automations"; -import { createSidebarSessionsStore } from "./context/sidebar-sessions"; -import { useGlobalSync } from "./context/global-sync"; -import { createWorkspaceStore } from "./context/workspace"; -import { - updaterEnvironment, - setWindowDecorations, -} from "./lib/tauri"; -import { - FONT_ZOOM_STEP, - applyWebviewZoom, - applyFontZoom, - normalizeFontZoom, - parseFontZoomShortcut, - persistFontZoom, - readStoredFontZoom, -} from "./lib/font-zoom"; -import { - buildOpenworkWorkspaceBaseUrl, - parseOpenworkWorkspaceIdFromUrl, - readOpenworkConnectInviteFromSearch, - stripOpenworkConnectInviteFromUrl, - hydrateOpenworkServerSettingsFromEnv, - normalizeOpenworkServerUrl, - readOpenworkServerSettings, - writeOpenworkServerSettings, - type OpenworkServerSettings, -} from "./lib/openwork-server"; -import { ReactIsland } from "../react/island"; -import { reactSessionEnabled } from "../react/feature-flag"; -import { ReactSessionRuntime } from "../react/session/runtime-sync.react"; -import { - parseBundleDeepLink, - stripBundleQuery, -} from "./bundles"; -import { createBundlesStore } from "./bundles/store"; -import { - classifyStartupBranch, - pushStartupTraceEvent, - type BootPhase, - type StartupBranch, - type StartupTraceEvent, -} from "./lib/startup-boot"; - -type SettingsReturnTarget = { - view: View; - tab: SettingsTab; - sessionId: string | null; -}; - -type PendingInitialSessionSelection = { - workspaceId: string; - title: string | null; - readyAt: number; -}; - -type RestrictionNotice = { - title: string; - message: string; -}; - -const STARTUP_SESSION_SNAPSHOT_KEY = "openwork.startupSessionSnapshot.v1"; -const STARTUP_SESSION_SNAPSHOT_VERSION = 1; -const STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE = 12; -const PROVIDER_RESTRICTION_MESSAGE = "Your administrator has restricted which providers and models are allowed. Please reach out to them to add new providers and models."; - -type StartupSessionSnapshotEntry = { - id: string; - title: string; - parentID?: string | null; - directory?: string | null; - time?: { - updated?: number | null; - created?: number | null; - }; -}; - -type StartupSessionSnapshot = { - version: number; - updatedAt: number; - sessionsByWorkspaceId: Record; -}; - -export default function App() { - const denAuth = useDenAuth(); - const desktopConfig = useDesktopConfig(); - const { resetSessionDisplayPreferences } = useSessionDisplayPreferences(); - const { microsandboxCreateSandboxEnabled } = useFeatureFlagsPreferences(); - const envOpenworkWorkspaceId = - typeof import.meta.env?.VITE_OPENWORK_WORKSPACE_ID === "string" - ? import.meta.env.VITE_OPENWORK_WORKSPACE_ID.trim() || null - : null; - - const location = useLocation(); - const navigate = useNavigate(); - - const [creatingSession, setCreatingSession] = createSignal(false); - const currentView = createMemo(() => { - const path = location.pathname.toLowerCase(); - if (path.startsWith("/signin")) return "signin"; - if (path.startsWith("/session")) return "session"; - return "settings"; - }); - const forceSigninEnabled = createMemo(() => readDenBootstrapConfig().requireSignin); - const blockingSigninPending = createMemo( - () => forceSigninEnabled() && denAuth.status() === "checking", - ); - - const [settingsTab, setSettingsTabState] = createSignal("general"); - const [pendingInitialSessionSelection, setPendingInitialSessionSelection] = - createSignal(null); - const [restrictionNotice, setRestrictionNotice] = createSignal(null); - - const goToSettings = (nextTab: SettingsTab, options?: { replace?: boolean }) => { - setSettingsTabState(nextTab); - navigate(`/settings/${nextTab}`, options); - }; - - const setSettingsTab = (nextTab: SettingsTab) => { - if (currentView() === "settings") { - goToSettings(nextTab); - return; - } - setSettingsTabState(nextTab); - }; - - const openCreateWorkspace = () => { - if (desktopConfig.checkRestriction({ restriction: "blockMultipleWorkspaces" })) { - setRestrictionNotice({ - title: "Additional workspaces are restricted", - message: "Your organization administrator has restricted access to adding additional workspaces.", - }); - return; - } - - workspaceStore.setCreateWorkspaceOpen(true); - }; - - const providerConnectionsRestricted = createMemo(() => - desktopConfig.checkRestriction({ restriction: "disallowNonCloudModels" }), - ); - - const setView = (next: View, sessionId?: string) => { - if (next === "signin") { - navigate("/signin"); - return; - } - if (next === "settings" && creatingSession()) { - return; - } - if (next === "session") { - if (sessionId) { - goToSession(sessionId); - return; - } - navigate("/session"); - return; - } - goToSettings(settingsTab()); - }; - - const goToSession = (sessionId: string, options?: { replace?: boolean }) => { - const trimmed = sessionId.trim(); - if (!trimmed) { - navigate("/session", options); - return; - } - navigate(`/session/${trimmed}`, options); - }; - - const [startupPreference, setStartupPreference] = createSignal( - readStartupPreference(), - ); - const [onboardingStep, setOnboardingStep] = - createSignal("welcome"); - const [rememberStartupChoice, setRememberStartupChoice] = createSignal(false); - const [themeMode, setThemeMode] = createSignal(getInitialThemeMode()); - - const [engineSource, setEngineSource] = createSignal<"path" | "sidecar" | "custom">( - isTauriRuntime() ? "sidecar" : "path" - ); - - const [engineCustomBinPath, setEngineCustomBinPath] = createSignal(""); - - const [engineRuntime, setEngineRuntime] = createSignal("openwork-orchestrator"); - const [opencodeEnableExa, setOpencodeEnableExa] = createSignal(false); - - const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096"); - const [clientDirectory, setClientDirectory] = createSignal(""); - - createEffect(() => { - if (typeof window === "undefined") return; - hydrateOpenworkServerSettingsFromEnv(); - - const stored = readOpenworkServerSettings(); - const invite = readOpenworkConnectInviteFromSearch(window.location.search); - const bundleInvite = parseBundleDeepLink(window.location.href); - - if (!invite) { - setOpenworkServerSettings(stored); - } else { - const merged: OpenworkServerSettings = { - ...stored, - urlOverride: invite.url, - token: invite.token ?? stored.token, - }; - - const next = writeOpenworkServerSettings(merged); - setOpenworkServerSettings(next); - - if (invite.startup === "server" && untrack(onboardingStep) === "welcome") { - setStartupPreference("server"); - setOnboardingStep("server"); - } - } - - if (bundleInvite?.bundleUrl) { - bundlesStore.queueBundleLink(window.location.href); - } - - if (invite?.autoConnect) { - deepLinks.queueRemoteConnectDefaults({ - openworkHostUrl: invite.url, - openworkToken: invite.token ?? null, - directory: null, - displayName: null, - autoConnect: true, - }); - } - - const cleanedConnect = stripOpenworkConnectInviteFromUrl(window.location.href); - const cleaned = stripBundleQuery(cleanedConnect) ?? cleanedConnect; - if (cleaned !== window.location.href) { - window.history.replaceState(window.history.state ?? null, "", cleaned); - } - }); - - createEffect(() => { - if (typeof document === "undefined") return; - const update = () => setDocumentVisible(document.visibilityState !== "hidden"); - update(); - document.addEventListener("visibilitychange", update); - onCleanup(() => document.removeEventListener("visibilitychange", update)); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!isTauriRuntime()) return; - - const applyAndPersistFontZoom = (value: number) => { - const next = normalizeFontZoom(value); - persistFontZoom(window.localStorage, next); - - try { - const webview = getCurrentWebview(); - void applyWebviewZoom(webview, next) - .then(() => { - document.documentElement.style.removeProperty("--openwork-font-size"); - }) - .catch(() => { - applyFontZoom(document.documentElement.style, next); - }); - } catch { - applyFontZoom(document.documentElement.style, next); - } - - return next; - }; - - let fontZoom = applyAndPersistFontZoom(readStoredFontZoom(window.localStorage) ?? 1); - - const handleZoomShortcut = (event: KeyboardEvent) => { - const action = parseFontZoomShortcut(event); - if (!action) return; - - if (action === "in") { - fontZoom = applyAndPersistFontZoom(fontZoom + FONT_ZOOM_STEP); - } else if (action === "out") { - fontZoom = applyAndPersistFontZoom(fontZoom - FONT_ZOOM_STEP); - } else { - fontZoom = applyAndPersistFontZoom(1); - } - - event.preventDefault(); - event.stopPropagation(); - }; - - window.addEventListener("keydown", handleZoomShortcut, true); - onCleanup(() => window.removeEventListener("keydown", handleZoomShortcut, true)); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!developerMode()) return; - if (!documentVisible()) return; - if (booting()) return; - if (workspaceStore?.connectingWorkspaceId?.()) return; - - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - try { - await workspaceStore.refreshEngine(); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - window.clearInterval(interval); - }); - }); - - const [client, setClient] = createSignal(null); - const [connectedVersion, setConnectedVersion] = createSignal( - null - ); - const [sseConnected, setSseConnected] = createSignal(false); - - const [busy, setBusy] = createSignal(false); - const [busyLabel, setBusyLabel] = createSignal(null); - const [busyStartedAt, setBusyStartedAt] = createSignal(null); - const [error, setError] = createSignal(null); - const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal(null); - const [booting, setBooting] = createSignal(true); - const [bootPhase, setBootPhase] = createSignal("nativeInit"); - const [startupBranch, setStartupBranch] = createSignal("unknown"); - const [startupTrace, setStartupTrace] = createSignal([]); - const [firstSidebarVisibleAt, setFirstSidebarVisibleAt] = createSignal(null); - const [firstSessionPaintAt, setFirstSessionPaintAt] = createSignal(null); - const [, setLastKnownConfigSnapshot] = createSignal(""); - const [developerMode, setDeveloperMode] = createSignal(false); - const [documentVisible, setDocumentVisible] = createSignal(true); - - const markStartupTrace = (phase: BootPhase, event: string, detail?: Record) => { - setStartupTrace((current) => - pushStartupTraceEvent(current, { - at: Date.now(), - phase, - event, - ...(detail ? { detail } : {}), - }), - ); - }; - - createEffect(() => { - const phase = bootPhase(); - const isBooting = phase !== "ready" && phase !== "error"; - setBooting(isBooting); - }); - - createEffect(() => { - if (bootPhase() === "ready" || bootPhase() === "error") return; - const message = error(); - if (!message) return; - setBootPhase("error"); - markStartupTrace("error", "startup-error", { message }); - }); - - createEffect(() => { - if (developerMode()) return; - clearDevLogs(); - clearPerfLogs(); - }); - - const [selectedSessionId, setSelectedSessionId] = createSignal(null); - const [prompt, setPrompt] = createSignal(""); - const [settingsReturnTarget, setSettingsReturnTarget] = createSignal({ - view: "settings", - tab: "general", - sessionId: null, - }); - const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; - const readSessionByWorkspace = () => { - if (typeof window === "undefined") return {} as Record; - try { - const raw = window.localStorage.getItem(SESSION_BY_WORKSPACE_KEY); - if (!raw) return {} as Record; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return {} as Record; - return parsed as Record; - } catch { - return {} as Record; - } - }; - const writeSessionByWorkspace = (map: Record) => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(SESSION_BY_WORKSPACE_KEY, JSON.stringify(map)); - } catch { - // ignore - } - }; - - const globalSync = useGlobalSync(); - const providers = createMemo(() => globalSync.data.provider.all ?? []); - const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {}); - const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []); - const connectedProviderIdSet = createMemo( - () => new Set(providerConnectedIds().map((providerId) => providerId.trim())), - ); - const visibleProviders = createMemo(() => - providers().filter((provider) => - !isDesktopProviderBlocked({ - providerId: provider.id, - checkRestriction: desktopConfig.checkRestriction, - }) && - (!providerConnectionsRestricted() || connectedProviderIdSet().has(provider.id.trim())), - ), - ); - const visibleProviderConnectedIds = createMemo(() => - providerConnectedIds().filter((providerId) => - !isDesktopProviderBlocked({ - providerId, - checkRestriction: desktopConfig.checkRestriction, - })), - ); - const setProviders = (value: ProviderListItem[]) => { - globalSync.set("provider", "all", value); - }; - const setProviderDefaults = (value: Record) => { - globalSync.set("provider", "default", value); - }; - const setProviderConnectedIds = (value: string[]) => { - globalSync.set("provider", "connected", value); - }; - - let workspaceStore!: ReturnType; - let sessionStore!: ReturnType; - let openworkServerStore!: ReturnType; - - const modelConfig = createModelConfigStore({ - client, - selectedSessionId, - messages: () => sessionStore?.messages?.() ?? [], - providers, - providerDefaults, - providerConnectedIds, - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceDisplay: () => - workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), - selectedWorkspacePath: () => workspaceStore?.selectedWorkspacePath?.() ?? "", - openworkServerClient: () => openworkServerStore?.openworkServerClient?.() ?? null, - openworkServerStatus: () => openworkServerStore?.openworkServerStatus?.() ?? "disconnected", - openworkServerCapabilities: () => openworkServerStore?.openworkServerCapabilities?.() ?? null, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - checkDesktopAppRestriction: desktopConfig.checkRestriction, - focusSessionPromptSoon: () => focusSessionPromptSoon(), - setError, - setLastKnownConfigSnapshot, - markOpencodeConfigReloadRequired: () => - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }), - }); - - createEffect(() => { - const view = currentView(); - const currentTab = settingsTab(); - if (view === "settings" || view === "signin") return; - setSettingsReturnTarget({ - view, - tab: currentTab, - sessionId: selectedSessionId(), - }); - }); - - const restoreSettingsReturnTarget = () => { - const target = settingsReturnTarget(); - if (target.view === "session") { - if (target.sessionId) { - goToSession(target.sessionId); - return; - } - navigate("/session"); - return; - } - goToSettings(target.tab); - }; - - const toggleSettingsView = (nextTab: SettingsTab = "general") => { - const settingsOpen = currentView() === "settings"; - if (settingsOpen) { - restoreSettingsReturnTarget(); - return; - } - setSettingsTab(nextTab); - goToSettings(nextTab); - }; - - let markReloadRequiredHandler: ((reason: ReloadReason, trigger?: ReloadTrigger) => void) | undefined; - const markReloadRequired = (reason: ReloadReason, trigger?: ReloadTrigger) => { - markReloadRequiredHandler?.(reason, trigger); - }; - - sessionStore = createSessionStore({ - client, - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot().trim(), - selectedSessionId, - setSelectedSessionId, - setPrompt, - sessionModelState: modelConfig.sessionModelState, - setSessionModelState: modelConfig.setSessionModelState, - lastUserModelFromMessages, - developerMode, - setError, - setSseConnected, - markReloadRequired, - onHotReloadApplied: () => { - void refreshSkills({ force: true }); - void refreshPlugins(pluginScope()); - void refreshMcpServers(); - }, - }); - - const { - sessions, - loadedScopeRoot: loadedSessionScopeRoot, - sessionById, - sessionStatusById, - messageIdFromInfo, - selectedSession, - selectedSessionStatus, - selectedSessionErrorTurns, - selectedSessionCompactionState, - messages, - visibleMessages, - messagesBySessionId, - todos, - pendingPermissions, - permissionReplyBusy, - activeQuestion, - questionReplyBusy, - events, - activePermission, - loadSessions, - ensureSessionLoaded, - refreshPendingPermissions, - selectSession, - loadEarlierMessages, - renameSession, - respondPermission, - respondQuestion, - restorePromptFromUserMessage, - upsertLocalSession, - setBlueprintSeedMessagesBySessionId, - setSessions, - setSessionStatusById, - setMessages, - setTodos, - setPendingPermissions, - selectedSessionHasEarlierMessages, - selectedSessionLoadingEarlierMessages, - sessionLoadingById, - } = sessionStore; - - const ARTIFACT_SCAN_MESSAGE_WINDOW = 220; - const artifacts = createMemo(() => - deriveArtifacts(messages(), { maxMessages: ARTIFACT_SCAN_MESSAGE_WINDOW }), - ); - const workingFiles = createMemo(() => deriveWorkingFiles(artifacts())); - const activeSessionId = createMemo(() => { - const path = location.pathname.trim(); - const [, sessionSegment, idSegment] = path.split("/"); - if (sessionSegment?.toLowerCase() === "session") { - const routeId = (idSegment ?? "").trim(); - if (routeId) return routeId; - } - return selectedSessionId(); - }); - const activeSessionStatusById = createMemo(() => sessionStatusById()); - const activeTodos = createMemo(() => todos()); - const activeWorkingFiles = createMemo(() => workingFiles()); - const [startupSessionSnapshotByWorkspaceId, setStartupSessionSnapshotByWorkspaceId] = createSignal< - Record - >({}); - - const [sessionsLoaded, setSessionsLoaded] = createSignal(false); - const loadSessionsWithReady = async (scopeRoot?: string) => { - await loadSessions(scopeRoot); - setSessionsLoaded(true); - }; - - createEffect(() => { - if (typeof window === "undefined") return; - try { - const raw = window.localStorage.getItem(STARTUP_SESSION_SNAPSHOT_KEY); - if (!raw) return; - const parsed = JSON.parse(raw) as StartupSessionSnapshot; - if (!parsed || parsed.version !== STARTUP_SESSION_SNAPSHOT_VERSION) return; - if (!parsed.sessionsByWorkspaceId || typeof parsed.sessionsByWorkspaceId !== "object") return; - setStartupSessionSnapshotByWorkspaceId(parsed.sessionsByWorkspaceId); - } catch { - // ignore malformed snapshots - } - }); - - createEffect(() => { - if (!client()) { - setSessionsLoaded(false); - } - }); - - const ensureWorkspaceRuntime = async (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return false; - const ready = await workspaceStore.activateWorkspace(id); - if (ready) { - await refreshSidebarWorkspaceSessions(id).catch(() => undefined); - } - return ready; - }; - - function focusSessionPromptSoon() { - if (typeof window === "undefined" || currentView() !== "session") return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - window.dispatchEvent(new CustomEvent("openwork:focusPrompt")); - }); - }); - } - - async function respondPermissionAndRemember( - requestID: string, - reply: "once" | "always" | "reject" - ) { - // Intentional no-op: permission prompts grant session-scoped access only. - // Persistent workspace roots must be managed explicitly via workspace settings. - await respondPermission(requestID, reply); - } - - openworkServerStore = createOpenworkServerStore({ - startupPreference, - documentVisible, - developerMode, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - activeClient: client, - selectedWorkspaceDisplay: () => - workspaceStore?.selectedWorkspaceDisplay?.() ?? ({ workspaceType: "local" } as WorkspaceDisplay), - restartLocalServer, - createRemoteWorkspaceFlow: async (input) => - (await workspaceStore?.createRemoteWorkspaceFlow?.(input)) ?? false, - }); - - const { - openworkServerSettings, - setOpenworkServerSettings, - updateOpenworkServerSettings, - resetOpenworkServerSettings, - shareRemoteAccessBusy, - shareRemoteAccessError, - saveShareRemoteAccess, - openworkServerUrl, - openworkServerBaseUrl, - openworkServerAuth, - openworkServerClient, - openworkServerStatus, - openworkServerCapabilities, - openworkServerReady, - openworkServerWorkspaceReady, - resolvedOpenworkCapabilities, - openworkServerCanWriteSkills, - openworkServerCanWritePlugins, - openworkServerHostInfo, - openworkServerDiagnostics, - openworkReconnectBusy, - opencodeRouterInfoState, - orchestratorStatusState, - openworkAuditEntries, - openworkAuditStatus, - openworkAuditError, - testOpenworkServerConnection, - reconnectOpenworkServer, - ensureLocalOpenworkServerClient, - } = openworkServerStore; - - const extensionsStore = createExtensionsStore({ - client, - projectDir: () => workspaceProjectDir(), - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", - workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", - openworkServer: openworkServerStore, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - setBusy, - setBusyLabel, - setBusyStartedAt, - setError, - markReloadRequired, - }); - - const { - skills, - skillsStatus, - pluginScope, - sidebarPluginList, - sidebarPluginStatus, - isPluginInstalledByName, - refreshSkills, - refreshHubSkills, - refreshPlugins, - addPlugin, - abortRefreshes, - } = extensionsStore; - - const connectionsStore = createConnectionsStore({ - client, - setClient, - projectDir: () => workspaceProjectDir(), - selectedWorkspaceId: () => workspaceStore?.selectedWorkspaceId?.() ?? "", - selectedWorkspaceRoot: () => workspaceStore?.selectedWorkspaceRoot?.() ?? "", - workspaceType: () => workspaceStore?.selectedWorkspaceDisplay?.().workspaceType ?? "local", - openworkServer: openworkServerStore, - runtimeWorkspaceId: () => workspaceStore?.runtimeWorkspaceId?.() ?? null, - ensureRuntimeWorkspaceId: () => workspaceStore?.ensureRuntimeWorkspaceId?.(), - setProjectDir: (value: string) => workspaceStore?.setProjectDir?.(value), - developerMode, - markReloadRequired, - }); - - const { refreshMcpServers } = connectionsStore; - - const [hideTitlebar, setHideTitlebar] = createSignal(false); - const { - defaultModel, - selectedSessionModel, - selectedSessionModelLabel, - defaultModelLabel, - defaultModelRef, - defaultModelVariantLabel, - modelVariant, - sessionModelVariantLabel, - sessionModelBehaviorOptions, - setSessionModelVariant, - sanitizeModelVariantForRef, - resolveCodexReasoningEffort, - modelPickerOpen, - modelPickerQuery, - setModelPickerQuery, - modelPickerTarget, - modelPickerCurrent, - modelOptions, - filteredModelOptions, - openSessionModelPicker, - openDefaultModelPicker, - closeModelPicker, - applyModelSelection, - setModelPickerBehavior, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextSaving, - } = modelConfig; - - workspaceStore = createWorkspaceStore({ - startupPreference, - setStartupPreference, - onboardingStep, - setOnboardingStep, - rememberStartupChoice, - setRememberStartupChoice, - baseUrl, - setBaseUrl, - clientDirectory, - setClientDirectory, - client, - setClient, - setConnectedVersion, - setSseConnected, - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setError, - setBusy, - setBusyLabel, - setBusyStartedAt, - setOpencodeConnectStatus, - loadSessions: loadSessionsWithReady, - refreshPendingPermissions, - refreshWorkspaceSessions: (workspaceId: string) => refreshSidebarWorkspaceSessions(workspaceId), - sessions, - sessionsLoaded, - creatingSession, - readLastSessionByWorkspace: readSessionByWorkspace, - selectedSessionId, - selectSession, - setBlueprintSeedMessagesBySessionId, - setSelectedSessionId, - setMessages, - setTodos, - setPendingPermissions, - setSessionStatusById, - defaultModel, - modelVariant, - refreshSkills, - refreshPlugins, - engineSource, - engineCustomBinPath, - opencodeEnableExa, - setEngineSource, - setView, - setSettingsTab, - isWindowsPlatform, - openworkServer: openworkServerStore, - openworkEnvWorkspaceId: envOpenworkWorkspaceId, - onEngineStable: () => {}, - onBootPhaseChange: (phase, detail) => { - setBootPhase(phase); - markStartupTrace(phase, "phase-change", detail); - }, - onStartupBranch: (branch, detail) => { - setStartupBranch(branch); - markStartupTrace(bootPhase(), "branch", { branch, ...(detail ?? {}) }); - }, - onStartupTrace: (event, detail) => { - markStartupTrace(bootPhase(), event, detail); - }, - engineRuntime, - developerMode, - pendingInitialSessionSelection, - setPendingInitialSessionSelection, - useMicrosandboxCreateSandbox: microsandboxCreateSandboxEnabled, - }); - - createEffect(() => { - if (startupBranch() !== "unknown") return; - const active = workspaceStore.selectedWorkspaceInfo?.() ?? null; - const derived = classifyStartupBranch({ - workspaceCount: workspaceStore.workspaces().length, - activeWorkspaceType: active?.workspaceType ?? null, - startupPreference: startupPreference(), - engineHasBaseUrl: Boolean(workspaceStore.engine()?.baseUrl), - selectedWorkspacePath: workspaceStore.selectedWorkspacePath?.() ?? "", - }); - if (derived !== "unknown") { - setStartupBranch(derived); - markStartupTrace(bootPhase(), "branch-derived", { branch: derived }); - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!developerMode()) return; - const payload = { - phase: bootPhase(), - branch: startupBranch(), - events: startupTrace(), - }; - try { - (window as { __openworkStartupTrace?: typeof payload }).__openworkStartupTrace = payload; - console.log("[startup-trace]", payload); - } catch { - // ignore trace publishing failures - } - }); - - const { - providerAuthModalOpen, - providerAuthBusy, - providerAuthError, - providerAuthMethods, - providerAuthProviders, - providerAuthPreferredProviderId, - providerAuthWorkerType, - cloudOrgProviders, - importedCloudProviders, - startProviderAuth, - refreshProviders, - refreshCloudOrgProviders, - completeProviderAuthOAuth, - submitProviderApiKey, - connectCloudProvider, - removeCloudProvider, - disconnectProvider, - runCloudProviderSync, - ensureProjectProviderDisabledState, - openProviderAuthModal: openProviderAuthModalInternal, - closeProviderAuthModal, - } = createProvidersStore({ - client, - providers, - providerDefaults, - providerConnectedIds, - disabledProviders: () => globalSync.data.config.disabled_providers ?? [], - selectedWorkspaceDisplay: () => workspaceStore.selectedWorkspaceDisplay(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceId: () => workspaceStore.runtimeWorkspaceId(), - checkDesktopAppRestriction: desktopConfig.checkRestriction, - openworkServer: openworkServerStore, - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setDisabledProviders: (value) => globalSync.set("config", "disabled_providers", value), - markOpencodeConfigReloadRequired: () => markOpencodeConfigReloadRequired(), - focusPromptSoon: focusSessionPromptSoon, - }); - - const openProviderAuthModal = async (optionsArg?: { - returnFocusTarget?: "none" | "composer"; - preferredProviderId?: string; - }) => { - if (providerConnectionsRestricted()) { - setRestrictionNotice({ - title: "Provider connections are restricted", - message: PROVIDER_RESTRICTION_MESSAGE, - }); - return; - } - - await openProviderAuthModalInternal(optionsArg); - }; - - let desktopRestrictionSyncKey = ""; - let desktopRestrictionSyncRunId = 0; - - createEffect(() => { - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (!workspaceId) { - desktopRestrictionSyncKey = ""; - return; - } - - const workspacePath = workspaceStore.selectedWorkspacePath().trim(); - const restrictionSnapshot = JSON.stringify(desktopConfig.config()); - const providerSnapshot = providers().map((provider) => provider.id).join(","); - const connectedSnapshot = providerConnectedIds().join(","); - const defaultModelSnapshot = modelConfig.defaultModelRef(); - const hasClient = Boolean(client()); - const nextKey = [ - workspaceId, - workspacePath, - restrictionSnapshot, - providerSnapshot, - connectedSnapshot, - defaultModelSnapshot, - hasClient ? "client" : "no-client", - ].join("::"); - - if (nextKey === desktopRestrictionSyncKey) { - return; - } - - desktopRestrictionSyncKey = nextKey; - const currentRun = ++desktopRestrictionSyncRunId; - - void runDesktopAppRestrictionSyncEffects({ - checkRestriction: desktopConfig.checkRestriction, - reconcileRestrictedModels: modelConfig.reconcileRestrictedModels, - ensureProjectProviderDisabledState, - onError: (error, details) => { - if (currentRun !== desktopRestrictionSyncRunId) { - return; - } - console.warn("[desktop-app-restrictions] effect failed", details, error); - }, - }); - }); - - const runtimeWorkspaceId = createMemo(() => workspaceStore.runtimeWorkspaceId()); - const activeWorkspaceServerConfig = createMemo(() => workspaceStore.runtimeWorkspaceConfig()); - const statusToastsStore = createStatusToastsStore(); - const bundlesStore = createBundlesStore({ - booting, - startupPreference, - openworkServer: openworkServerStore, - runtimeWorkspaceId, - workspaceStore, - setError, - error, - setView, - setSettingsTab, - refreshActiveWorkspaceServerConfig: workspaceStore.refreshRuntimeWorkspaceConfig, - refreshSkills, - refreshHubSkills, - markReloadRequired, - showStatusToast: statusToastsStore.showToast, - }); - - const deepLinks = createDeepLinksController({ - booting, - setError, - setView, - setSettingsTab, - goToSettings, - workspaceStore, - bundlesStore, - }); - - const sidebarSessionsStore = createSidebarSessionsStore({ - workspaces: () => workspaceStore.workspaces(), - engine: () => workspaceStore.engine(), - }); - - const { - workspaceGroups: rawSidebarWorkspaceGroups, - refreshWorkspaceSessions: refreshSidebarWorkspaceSessions, - } = sidebarSessionsStore; - - const sessionActionsStore = createSessionActionsStore({ - client, - baseUrl, - developerMode, - prompt, - setPrompt, - selectedSessionId, - selectedSession, - sessions, - messages, - setSessions, - sessionStatusById, - setSessionStatusById, - setBusy, - setBusyLabel, - setBusyStartedAt, - setCreatingSession, - setError, - selectWorkspace: workspaceStore.selectWorkspace, - workspaceRootForId: workspaceStore.workspaceRootForId, - selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceRoot: () => workspaceStore.runtimeWorkspaceRoot(), - ensureWorkspaceRuntime, - selectSession, - refreshSidebarWorkspaceSessions, - abortRefreshes, - modelConfig, - selectedSessionModel: () => selectedSessionModel(), - modelVariant, - sanitizeModelVariantForRef: (ref, value) => sanitizeModelVariantForRef(ref, value), - resolveCodexReasoningEffort: (modelId, variant) => resolveCodexReasoningEffort(modelId, variant), - messageIdFromInfo, - restorePromptFromUserMessage, - upsertLocalSession, - readSessionByWorkspace, - writeSessionByWorkspace, - setSelectedSessionId, - locationPath: () => location.pathname, - navigate, - renameSession, - appendSessionErrorTurn: sessionStore.appendSessionErrorTurn, - }); - - const { - lastPromptSent, - selectedSessionAgent, - sessionRevertMessageId, - createSessionInWorkspace, - createSessionAndOpen, - sendPrompt, - abortSession, - retryLastPrompt, - compactCurrentSession, - undoLastUserMessage, - redoLastUserMessage, - renameSessionTitle, - deleteSessionById, - listAgents, - listCommands, - setSessionAgent, - searchWorkspaceFiles, - } = sessionActionsStore; - - const sidebarWorkspaceGroups = createMemo(() => { - const groups = rawSidebarWorkspaceGroups(); - const selectedWorkspaceId = workspaceStore.selectedWorkspaceId().trim(); - const connectingWorkspaceId = workspaceStore.connectingWorkspaceId()?.trim() ?? ""; - const dedupedGroups: typeof groups = []; - const dedupeKeyToIndex = new Map(); - for (const group of groups) { - const workspace = group.workspace; - if (workspace.workspaceType !== "remote") { - dedupedGroups.push(group); - continue; - } - const hostKey = - normalizeOpenworkServerUrl(workspace.openworkHostUrl?.trim() ?? "") ?? - normalizeOpenworkServerUrl(workspace.baseUrl?.trim() ?? "") ?? - ""; - const workspaceIdKey = - workspace.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || - ""; - const directoryKey = normalizeDirectoryPath(workspace.directory?.trim() ?? workspace.path?.trim() ?? ""); - const identityKey = workspaceIdKey ? `id:${workspaceIdKey}` : (directoryKey ? `dir:${directoryKey}` : ""); - if (!hostKey || !identityKey) { - dedupedGroups.push(group); - continue; - } - const dedupeKey = `${workspace.remoteType ?? ""}|${hostKey}|${identityKey}`; - const existingIndex = dedupeKeyToIndex.get(dedupeKey); - if (existingIndex === undefined) { - dedupeKeyToIndex.set(dedupeKey, dedupedGroups.length); - dedupedGroups.push(group); - continue; - } - const existingWorkspace = dedupedGroups[existingIndex].workspace; - const existingIsPriority = - existingWorkspace.id === selectedWorkspaceId || existingWorkspace.id === connectingWorkspaceId; - const currentIsPriority = - workspace.id === selectedWorkspaceId || workspace.id === connectingWorkspaceId; - if (currentIsPriority && !existingIsPriority) { - dedupedGroups[existingIndex] = group; - } - } - return dedupedGroups.map((group) => { - const workspace = group.workspace; - const groupSessions = group.sessions; - if (developerMode()) { - console.log("[sidebar-groups] workspace group", { - workspaceId: workspace.id, - workspaceName: workspace.name, - workspaceType: workspace.workspaceType, - workspacePath: workspace.path, - workspaceDirectory: workspace.directory, - sessionCount: groupSessions.length, - sessions: groupSessions.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - }); - } - return { - workspace, - sessions: groupSessions, - status: group.status, - error: group.error, - }; - }); - }); - - const hydratedSidebarWorkspaceGroups = createMemo(() => { - const liveGroups = sidebarWorkspaceGroups(); - if (liveGroups.some((group) => group.sessions.length > 0)) { - return liveGroups; - } - - const snapshotByWorkspaceId = startupSessionSnapshotByWorkspaceId(); - if (!snapshotByWorkspaceId || Object.keys(snapshotByWorkspaceId).length === 0) { - return liveGroups; - } - - return liveGroups.map((group) => { - if (group.sessions.length > 0) return group; - const cachedSessions = snapshotByWorkspaceId[group.workspace.id] ?? []; - if (!cachedSessions.length) return group; - return { - ...group, - sessions: cachedSessions, - }; - }); - }); - - const sidebarHydratedFromCache = createMemo(() => { - const liveGroups = sidebarWorkspaceGroups(); - const hydratedGroups = hydratedSidebarWorkspaceGroups(); - if (!hydratedGroups.length) return false; - if (liveGroups.length !== hydratedGroups.length) return false; - return hydratedGroups.some((group, index) => { - const liveGroup = liveGroups[index]; - if (!liveGroup) return false; - return liveGroup.sessions.length === 0 && group.sessions.length > 0; - }); - }); - - createEffect(() => { - if (firstSidebarVisibleAt()) return; - const anyRowsVisible = hydratedSidebarWorkspaceGroups().some((group) => group.sessions.length > 0); - if (!anyRowsVisible) return; - const at = Date.now(); - setFirstSidebarVisibleAt(at); - markStartupTrace(bootPhase(), "first-sidebar-visible", { - at, - source: sidebarHydratedFromCache() ? "cache" : "live", - }); - }); - - createEffect(() => { - if (firstSessionPaintAt()) return; - if (currentView() !== "session") return; - const selected = activeSessionId(); - if (!selected) return; - const hasVisibleSessionSurface = visibleMessages().length > 0 || sessionsLoaded(); - if (!hasVisibleSessionSurface) return; - const at = Date.now(); - setFirstSessionPaintAt(at); - markStartupTrace(bootPhase(), "first-session-paint", { at, sessionId: selected }); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!sessionsLoaded()) return; - - const groups = sidebarWorkspaceGroups(); - const sessionsByWorkspaceId: Record = {}; - for (const group of groups) { - if (!group.sessions.length) continue; - sessionsByWorkspaceId[group.workspace.id] = group.sessions - .slice(0, STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE) - .map((session) => ({ - id: session.id, - title: session.title, - parentID: session.parentID ?? null, - directory: session.directory ?? null, - time: session.time, - })); - } - if (Object.keys(sessionsByWorkspaceId).length === 0) return; - - const payload: StartupSessionSnapshot = { - version: STARTUP_SESSION_SNAPSHOT_VERSION, - updatedAt: Date.now(), - sessionsByWorkspaceId, - }; - - try { - window.localStorage.setItem(STARTUP_SESSION_SNAPSHOT_KEY, JSON.stringify(payload)); - setStartupSessionSnapshotByWorkspaceId(sessionsByWorkspaceId); - } catch { - // ignore storage write failures - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = workspaceStore.selectedWorkspaceId(); - const sessionId = selectedSessionId(); - if (!workspaceId || !sessionId) return; - const map = readSessionByWorkspace(); - if (map[workspaceId] === sessionId) return; - map[workspaceId] = sessionId; - writeSessionByWorkspace(map); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const pending = pendingInitialSessionSelection(); - if (!pending) return; - const delayMs = pending.readyAt - Date.now(); - if (delayMs <= 0) return; - const timer = window.setTimeout(() => { - setPendingInitialSessionSelection((current) => - current && current.workspaceId === pending.workspaceId && current.readyAt === pending.readyAt - ? { ...current } - : current, - ); - }, delayMs); - onCleanup(() => window.clearTimeout(timer)); - }); - - createEffect(() => { - const pending = pendingInitialSessionSelection(); - if (!pending) return; - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (!workspaceId || pending.workspaceId !== workspaceId) return; - const path = location.pathname.trim().toLowerCase(); - if (path.startsWith("/session/") || !!selectedSessionId()) { - setPendingInitialSessionSelection(null); - } - }); - - createEffect(() => { - // Only auto-select on bare /session. If the URL already includes /session/:id, - // let the route-driven selector own the fetch to avoid duplicate selection runs. - const pending = pendingInitialSessionSelection(); - const workspaceId = workspaceStore.selectedWorkspaceId().trim(); - if (pending && pending.workspaceId === workspaceId) { - if (Date.now() < pending.readyAt) return; - if (!sessionsLoaded()) return; - if (sessions().length === 0) return; - const workspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); - const normalizedTitle = pending.title?.trim().toLowerCase() ?? ""; - const match = normalizedTitle - ? sessions().find((session) => { - const sessionTitle = session.title?.trim().toLowerCase() ?? ""; - if (sessionTitle !== normalizedTitle) return false; - if (!workspaceRoot) return true; - const sessionRoot = normalizeDirectoryPath(typeof session.directory === "string" ? session.directory : ""); - return sessionRoot === workspaceRoot; - }) - : null; - if (match) { - goToSession(match.id, { replace: true }); - return; - } - setPendingInitialSessionSelection(null); - setView("session"); - return; - } - - if (currentView() !== "session") return; - const normalizedPath = location.pathname.toLowerCase().replace(/\/+$/, ""); - if (normalizedPath !== "/session") return; - if (!client()) return; - if (!sessionsLoaded()) return; - if (creatingSession()) return; - if (selectedSessionId()) return; - - // Keep /session as a draft-ready empty state until the user picks a session - // or sends a prompt. Avoid auto-selecting prior sessions on app launch. - return; - }); - - createEffect(() => { - const active = workspaceStore.selectedWorkspaceDisplay(); - if (active.workspaceType !== "remote" || active.remoteType !== "openwork") { - return; - } - const hostUrl = active.openworkHostUrl?.trim() ?? ""; - if (!hostUrl) return; - const token = active.openworkToken?.trim() ?? ""; - const settings = openworkServerSettings(); - if (settings.urlOverride?.trim() === hostUrl && (!token || settings.token?.trim() === token)) { - return; - } - updateOpenworkServerSettings({ - ...settings, - urlOverride: hostUrl, - token: token || settings.token, - }); - }); - - async function restartLocalServer() { - const activeWorkspace = workspaceStore.selectedWorkspaceDisplay(); - const activeLocalPath = - activeWorkspace.workspaceType === "local" ? workspaceStore.selectedWorkspacePath().trim() : ""; - const runningProjectDir = workspaceStore.engine()?.projectDir?.trim() ?? ""; - const workspacePath = activeLocalPath || runningProjectDir; - - if (!workspacePath) { - setError(t("app.error_pick_local_folder")); - return false; - } - - return workspaceStore.startHost({ workspacePath, navigate: false }); - } - - const canReloadLocalEngine = () => - isTauriRuntime() && workspaceStore.selectedWorkspaceDisplay().workspaceType === "local"; - - const canReloadWorkspace = createMemo(() => { - if (canReloadLocalEngine()) return true; - if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") return false; - return openworkServerStatus() === "connected" && Boolean(openworkServerClient() && runtimeWorkspaceId()); - }); - - const reloadWorkspaceEngineFromUi = async () => { - if (canReloadLocalEngine()) { - return workspaceStore.reloadWorkspaceEngine(); - } - - if (workspaceStore.selectedWorkspaceDisplay().workspaceType !== "remote") { - return false; - } - - const client = openworkServerClient(); - const workspaceId = runtimeWorkspaceId(); - if (!client || !workspaceId || openworkServerStatus() !== "connected") { - setError(t("app.error_connect_first")); - return false; - } - - try { - await client.reloadEngine(workspaceId); - await workspaceStore.activateWorkspace(workspaceStore.selectedWorkspaceId()); - await refreshMcpServers(); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : t("app.error_runtime_changes"); - setError(message); - return false; - } - }; - - const systemState = createSystemState({ - client, - sessions, - sessionStatusById, - refreshPlugins, - refreshSkills, - refreshMcpServers, - reloadWorkspaceEngine: reloadWorkspaceEngineFromUi, - canReloadWorkspaceEngine: () => canReloadWorkspace(), - setProviders, - setProviderDefaults, - setProviderConnectedIds, - setError, - }); - - const { - reloadPending, - reloadCopy, - reloadTrigger, - reloadBusy, - reloadError, - reloadWorkspaceEngine, - clearReloadRequired, - cacheRepairBusy, - cacheRepairResult, - repairOpencodeCache, - dockerCleanupBusy, - dockerCleanupResult, - cleanupOpenworkDockerContainers, - updateAutoCheck, - setUpdateAutoCheck, - updateAutoDownload, - setUpdateAutoDownload, - updateStatus, - setUpdateStatus, - pendingUpdate, - updateEnv, - setUpdateEnv, - checkForUpdates, - downloadUpdate, - installUpdateAndRestart, - resetModalOpen, - setResetModalOpen, - resetModalMode, - resetModalText, - setResetModalText, - resetModalBusy, - openResetModal, - confirmReset, - anyActiveRuns, - } = systemState; - - markReloadRequiredHandler = systemState.markReloadRequired; - - const UPDATE_AUTO_CHECK_EVERY_MS = 12 * 60 * 60_000; - const UPDATE_AUTO_CHECK_POLL_MS = 60_000; - - const resetAppConfigDefaults = async () => { - try { - setThemeMode("system"); - setEngineSource(isTauriRuntime() ? "sidecar" : "path"); - setEngineCustomBinPath(""); - setEngineRuntime("openwork-orchestrator"); - modelConfig.resetAppDefaults(); - resetSessionDisplayPreferences(); - setHideTitlebar(false); - setUpdateAutoCheck(true); - setUpdateAutoDownload(false); - setUpdateStatus({ state: "idle", lastCheckedAt: null }); - setDeveloperMode(false); - - clearStartupPreference(); - setStartupPreference(null); - setRememberStartupChoice(false); - - resetOpenworkServerSettings(); - - return { ok: true, message: t("app.reset_config_ok") }; - } catch (error) { - const message = error instanceof Error ? error.message : t("app.error_reset_config"); - return { ok: false, message }; - } - }; - - const getUpdateLastCheckedAt = (state: ReturnType) => { - if (state.state === "checking") return null; - return state.lastCheckedAt ?? null; - }; - - const shouldAutoCheckForUpdates = () => { - const state = updateStatus(); - const lastCheckedAt = getUpdateLastCheckedAt(state); - if (!lastCheckedAt) return true; - return Date.now() - lastCheckedAt >= UPDATE_AUTO_CHECK_EVERY_MS; - }; - - const isActiveSessionStatus = (status: string | null | undefined) => - status === "running" || status === "retry"; - - const reloadRequired = (...sources: ReloadTrigger["type"][]) => { - if (!reloadPending()) return false; - const triggerType = reloadTrigger()?.type; - if (!triggerType) return false; - if (!sources.length) return true; - return sources.includes(triggerType); - }; - - const markOpencodeConfigReloadRequired = () => { - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); - }; - - const activeReloadBlockingSessions = createMemo(() => { - const statuses = sessionStatusById(); - return sessions() - .filter((session) => isActiveSessionStatus(statuses[session.id])) - .map((session) => ({ - id: session.id, - title: session.title?.trim() || session.slug?.trim() || session.id, - })); - }); - - const forceStopActiveSessionsAndReload = async () => { - const activeSessions = activeReloadBlockingSessions(); - for (const session of activeSessions) { - try { - await abortSession(session.id); - } catch { - // ignore and continue stopping the rest before reload - } - } - await reloadWorkspaceEngine(); - }; - - const { - projectDir: workspaceProjectDir, - stopHost, - } = workspaceStore; - - const schedulerPluginInstalled = createMemo(() => isPluginInstalledByName("opencode-scheduler")); - - const automationsStore = createAutomationsStore({ - selectedWorkspaceId: () => workspaceStore.selectedWorkspaceId(), - selectedWorkspaceRoot: () => workspaceStore.selectedWorkspaceRoot(), - runtimeWorkspaceId, - openworkServer: openworkServerStore, - schedulerPluginInstalled, - }); - - const { - scheduledJobsPollingAvailable, - refreshScheduledJobs, - } = automationsStore; - - createEffect(() => { - if (typeof window === "undefined") return; - if (currentView() !== "settings") return; - if (settingsTab() !== "automations") return; - if (!documentVisible()) return; - - const pollingAvailable = scheduledJobsPollingAvailable(); - const startedAt = Date.now(); - let active = true; - let failureCount = 0; - let timeoutId: number | undefined; - - const clearTimer = () => { - if (timeoutId == null) return; - window.clearTimeout(timeoutId); - timeoutId = undefined; - }; - - const nextDelayMs = () => { - const baseDelay = Date.now() - startedAt < 60_000 ? 5_000 : 15_000; - if (failureCount <= 0) return baseDelay; - return Math.min(baseDelay * 2 ** failureCount, 60_000); - }; - - const scheduleNext = () => { - clearTimer(); - if (!active || !pollingAvailable) return; - timeoutId = window.setTimeout(() => { - void run("poll"); - }, nextDelayMs()); - }; - - const run = async (_reason: "initial" | "focus" | "poll") => { - if (!active) return; - const result = await refreshScheduledJobs(); - if (!active) return; - - if (result === "error") { - failureCount += 1; - } else if (result === "success" || result === "unavailable") { - failureCount = 0; - } - - scheduleNext(); - }; - - const handleFocus = () => { - clearTimer(); - void run("focus"); - }; - - void run("initial"); - window.addEventListener("focus", handleFocus); - - onCleanup(() => { - active = false; - clearTimer(); - window.removeEventListener("focus", handleFocus); - }); - }); - - const selectedWorkspaceDisplay = createMemo(() => workspaceStore.selectedWorkspaceDisplay()); - const resolvedActiveWorkspaceConfig = createMemo( - () => activeWorkspaceServerConfig() ?? workspaceStore.workspaceConfig(), - ); - const activePermissionMemo = createMemo(() => activePermission()); - - const [expandedStepIds, setExpandedStepIds] = createSignal>( - new Set() - ); - const [autoConnectAttempted, setAutoConnectAttempted] = createSignal(false); - - const [appVersion, setAppVersion] = createSignal(null); - const [launchUpdateCheckTriggered, setLaunchUpdateCheckTriggered] = createSignal(false); - - const logAppUpdateLifecycle = (label: string, payload?: unknown) => { - try { - if (payload === undefined) { - console.log(`[APP-UPDATES] ${label}`); - } else { - console.log(`[APP-UPDATES] ${label}`, payload); - } - } catch { - // ignore - } - }; - - - const busySeconds = createMemo(() => { - const start = busyStartedAt(); - if (!start) return 0; - return Math.max(0, Math.round((Date.now() - start) / 1000)); - }); - - const newTaskDisabled = createMemo(() => { - if (!client()) { - return true; - } - - const label = busyLabel(); - // Allow creating a new session even while a run is in progress. - if (busy() && label === "status.running") return false; - - // Otherwise, block during engine / connection transitions. - if ( - busy() && - (label === "status.connecting" || - label === "status.starting_engine" || - label === "status.disconnecting") - ) { - return true; - } - - return busy(); - }); - - createEffect(() => { - if (isTauriRuntime()) return; - if (autoConnectAttempted()) return; - if (client()) return; - if (openworkServerStatus() !== "connected") return; - - const settings = openworkServerSettings(); - if (!settings.urlOverride || !settings.token) return; - - setAutoConnectAttempted(true); - void workspaceStore.onConnectClient(); - }); - - function openSettingsFromModelPicker() { - setSettingsTab("general"); - setView("settings"); - } - - - onMount(async () => { - const startupPref = readStartupPreference(); - if (startupPref) { - setRememberStartupChoice(true); - setStartupPreference(startupPref); - } - - const unsubscribeTheme = subscribeToSystemTheme((isDark) => { - if (themeMode() !== "system") return; - applyThemeMode(isDark ? "dark" : "light"); - }); - - onCleanup(() => { - unsubscribeTheme(); - }); - - createEffect(() => { - const next = themeMode(); - persistThemeMode(next); - applyThemeMode(next); - }); - - if (typeof window !== "undefined") { - try { - // In Tauri/desktop mode, do NOT restore the cached baseUrl from localStorage. - // OpenCode is assigned a random port on every restart, so the stored URL is - // always stale after a relaunch. The correct baseUrl is provided by engine_info(). - // Web mode still needs the cached value since it connects to a fixed server URL. - if (!isTauriRuntime()) { - const storedBaseUrl = window.localStorage.getItem("openwork.baseUrl"); - if (storedBaseUrl) { - setBaseUrl(storedBaseUrl); - } - } - - const storedClientDir = window.localStorage.getItem( - "openwork.clientDirectory" - ); - if (storedClientDir) { - setClientDirectory(storedClientDir); - } - - const storedEngineSource = window.localStorage.getItem( - "openwork.engineSource" - ); - const storedEngineCustomBinPath = window.localStorage.getItem( - "openwork.engineCustomBinPath" - ); - if (storedEngineCustomBinPath) { - setEngineCustomBinPath(storedEngineCustomBinPath); - } - if ( - storedEngineSource === "path" || - storedEngineSource === "sidecar" || - storedEngineSource === "custom" - ) { - if (storedEngineSource === "custom" && !(storedEngineCustomBinPath ?? "").trim()) { - setEngineSource(isTauriRuntime() ? "sidecar" : "path"); - } else { - setEngineSource(storedEngineSource); - } - } - - const storedEngineRuntime = window.localStorage.getItem( - "openwork.engineRuntime" - ); - if (storedEngineRuntime === "direct" || storedEngineRuntime === "openwork-orchestrator") { - setEngineRuntime(storedEngineRuntime); - } - - const storedOpencodeEnableExa = window.localStorage.getItem( - "openwork.opencodeEnableExa" - ); - if (storedOpencodeEnableExa === "0" || storedOpencodeEnableExa === "1") { - setOpencodeEnableExa(storedOpencodeEnableExa === "1"); - } - - const storedHideTitlebar = window.localStorage.getItem(HIDE_TITLEBAR_PREF_KEY); - if (storedHideTitlebar != null) { - try { - const parsed = JSON.parse(storedHideTitlebar); - if (typeof parsed === "boolean") { - setHideTitlebar(parsed); - } - } catch { - // ignore - } - } - - const storedUpdateAutoCheck = window.localStorage.getItem( - "openwork.updateAutoCheck" - ); - if (storedUpdateAutoCheck === "0" || storedUpdateAutoCheck === "1") { - setUpdateAutoCheck(storedUpdateAutoCheck === "1"); - } - - const storedUpdateAutoDownload = window.localStorage.getItem( - "openwork.updateAutoDownload" - ); - if (storedUpdateAutoDownload === "0" || storedUpdateAutoDownload === "1") { - const enabled = storedUpdateAutoDownload === "1"; - setUpdateAutoDownload(enabled); - if (enabled) { - setUpdateAutoCheck(true); - } - } - - const storedUpdateCheckedAt = window.localStorage.getItem( - "openwork.updateLastCheckedAt" - ); - if (storedUpdateCheckedAt) { - const parsed = Number(storedUpdateCheckedAt); - if (Number.isFinite(parsed) && parsed > 0) { - setUpdateStatus({ state: "idle", lastCheckedAt: parsed }); - } - } - - await refreshMcpServers(); - } catch { - // ignore - } - } - - if (isTauriRuntime()) { - try { - setAppVersion(await getVersion()); - } catch { - // ignore - } - - try { - setUpdateEnv(await updaterEnvironment()); - } catch { - // ignore - } - - if (!launchUpdateCheckTriggered() && denAuth.status() !== "checking") { - logAppUpdateLifecycle("mount-triggering-launch-update-check", { - denAuthStatus: denAuth.status(), - }); - setLaunchUpdateCheckTriggered(true); - checkForUpdates({ quiet: true }).catch(() => undefined); - } - } - - if (typeof window !== "undefined") { - const handleDeepLinkEvent = (event: Event) => { - const detail = (event as CustomEvent).detail; - deepLinks.consumeDeepLinks(detail?.urls ?? []); - }; - - deepLinks.consumeDeepLinks(drainPendingDeepLinks(window)); - window.addEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); - onCleanup(() => { - window.removeEventListener(deepLinkBridgeEvent, handleDeepLinkEvent as EventListener); - }); - } - - void workspaceStore.bootstrapOnboarding(); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (onboardingStep() !== "local") return; - void workspaceStore.refreshEngineDoctor(); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.baseUrl", baseUrl()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.clientDirectory", - clientDirectory() - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - // Legacy key: keep for backwards compatibility. - try { - window.localStorage.setItem("openwork.projectDir", workspaceProjectDir()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.engineSource", engineSource()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - const value = engineCustomBinPath().trim(); - if (value) { - window.localStorage.setItem("openwork.engineCustomBinPath", value); - } else { - window.localStorage.removeItem("openwork.engineCustomBinPath"); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem("openwork.engineRuntime", engineRuntime()); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.opencodeEnableExa", - opencodeEnableExa() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.updateAutoCheck", - updateAutoCheck() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - "openwork.updateAutoDownload", - updateAutoDownload() ? "1" : "0" - ); - } catch { - // ignore - } - }); - - // Persist and apply hideTitlebar setting - createEffect(() => { - if (typeof window === "undefined") return; - const hide = hideTitlebar(); - try { - window.localStorage.setItem(HIDE_TITLEBAR_PREF_KEY, JSON.stringify(hide)); - } catch { - // ignore - } - // Apply to window decorations (only in Tauri desktop environment) - if (isTauriRuntime()) { - setWindowDecorations(!hide).catch(() => { - // ignore errors (e.g., window not ready) - }); - } - }); - - createEffect(() => { - const state = updateStatus(); - if (typeof window === "undefined") return; - if (state.state === "idle" && state.lastCheckedAt) { - try { - window.localStorage.setItem( - "openwork.updateLastCheckedAt", - String(state.lastCheckedAt) - ); - } catch { - // ignore - } - } - }); - - createEffect(() => { - logAppUpdateLifecycle("den-auth-status", { - status: denAuth.status(), - isSignedIn: denAuth.isSignedIn(), - }); - }); - - createEffect(() => { - if (booting()) return; - if (!isTauriRuntime()) return; - if (launchUpdateCheckTriggered()) { - logAppUpdateLifecycle("launch-update-check-skipped-already-triggered"); - return; - } - if (denAuth.status() === "checking") { - logAppUpdateLifecycle("launch-update-check-waiting-for-den-auth", { - denAuthStatus: denAuth.status(), - }); - return; - } - - const state = updateStatus(); - if (state.state === "checking" || state.state === "downloading") return; - - logAppUpdateLifecycle("effect-triggering-launch-update-check", { - denAuthStatus: denAuth.status(), - updateState: state.state, - }); - setLaunchUpdateCheckTriggered(true); - checkForUpdates({ quiet: true }).catch(() => undefined); - }); - - createEffect(() => { - if (booting()) return; - if (typeof window === "undefined") return; - if (!isTauriRuntime()) return; - if (!launchUpdateCheckTriggered()) return; - if (!updateAutoCheck()) return; - - const maybeRunAutoUpdateCheck = () => { - if (!updateAutoCheck()) return; - const state = updateStatus(); - if (state.state === "checking" || state.state === "downloading") return; - if (!shouldAutoCheckForUpdates()) return; - checkForUpdates({ quiet: true }).catch(() => undefined); - }; - - const interval = window.setInterval(maybeRunAutoUpdateCheck, UPDATE_AUTO_CHECK_POLL_MS); - onCleanup(() => window.clearInterval(interval)); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!updateAutoDownload()) return; - - const state = updateStatus(); - if (state.state !== "available") return; - if (!pendingUpdate()) return; - - downloadUpdate().catch(() => undefined); - }); - - const headerConnectedVersion = createMemo(() => { - const fallbackVersion = connectedVersion()?.trim() ?? ""; - if (!developerMode()) { - return fallbackVersion || null; - } - - const openworkVersion = - appVersion()?.trim() || - openworkServerDiagnostics()?.version?.trim() || - ""; - if (!openworkVersion) { - return fallbackVersion || null; - } - - const normalizedVersion = openworkVersion.startsWith("v") - ? openworkVersion - : `v${openworkVersion}`; - return `OpenWork ${normalizedVersion}`; - }); - - const headerStatus = createMemo(() => { - if (!client() || !headerConnectedVersion()) return t("status.disconnected", currentLocale()); - const bits = [`${t("status.connected", currentLocale())} · ${headerConnectedVersion()}`]; - if (sseConnected()) bits.push(t("status.live", currentLocale())); - return bits.join(" · "); - }); - - const busyHint = createMemo(() => { - if (!busy() || !busyLabel()) return null; - const seconds = busySeconds(); - const label = t(busyLabel()!, currentLocale()); - return seconds > 0 ? `${label} · ${seconds}s` : label; - }); - - const modelControlsStore = createModelControlsStore({ - selectedSessionModelLabel, - openSessionModelPicker, - sessionModelVariantLabel, - sessionModelVariant: modelVariant, - sessionModelBehaviorOptions, - setSessionModelVariant, - defaultModelLabel, - defaultModelRef, - openDefaultModelPicker, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextBusy: autoCompactContextSaving, - defaultModelVariantLabel, - editDefaultModelVariant: openDefaultModelPicker, - }); - - const settingsShellProps = () => { - const workspaceType = selectedWorkspaceDisplay().workspaceType; - const isRemoteWorkspace = workspaceType === "remote"; - const openworkStatus = openworkServerStatus(); - const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace; - const canInstallSkillCreator = isRemoteWorkspace - ? openworkServerCanWriteSkills() - : isTauriRuntime(); - const canEditPlugins = isRemoteWorkspace - ? openworkServerCanWritePlugins() - : isTauriRuntime(); - const canUseGlobalPluginScope = !isRemoteWorkspace && isTauriRuntime(); - const skillsAccessHint = isRemoteWorkspace - ? openworkStatus === "disconnected" - ? t("app.skills_hint_disconnected") - : openworkStatus === "limited" - ? t("app.skills_hint_limited") - : openworkServerCanWriteSkills() - ? null - : t("app.skills_hint_readonly") - : null; - const pluginsAccessHint = isRemoteWorkspace - ? openworkStatus === "disconnected" - ? t("app.plugins_hint_disconnected") - : openworkStatus === "limited" - ? t("app.plugins_hint_limited") - : openworkServerCanWritePlugins() - ? null - : t("app.plugins_hint_readonly") - : null; - - return { - settingsTab: settingsTab(), - setSettingsTab, - providers: visibleProviders(), - providerConnectedIds: visibleProviderConnectedIds(), - providerAuthBusy: providerAuthBusy(), - providerAuthModalOpen: providerAuthModalOpen(), - providerAuthError: providerAuthError(), - providerAuthMethods: providerAuthMethods(), - providerAuthProviders: providerAuthProviders(), - providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providerAuthWorkerType: providerAuthWorkerType(), - cloudOrgProviders: cloudOrgProviders(), - importedCloudProviders: importedCloudProviders(), - openProviderAuthModal, - disconnectProvider, - removeCloudProvider, - runCloudProviderSync, - closeProviderAuthModal, - startProviderAuth, - completeProviderAuthOAuth, - refreshProviders, - refreshCloudOrgProviders, - submitProviderApiKey, - connectCloudProvider, - setView, - toggleSettings: () => toggleSettingsView("general"), - startupPreference: startupPreference(), - baseUrl: baseUrl(), - clientConnected: Boolean(client()), - busy: busy(), - busyHint: busyHint(), - newTaskDisabled: newTaskDisabled(), - headerStatus: headerStatus(), - error: error(), - openworkServerStatus: openworkStatus, - openworkServerUrl: openworkServerUrl(), - openworkServerClient: openworkServerClient(), - openworkReconnectBusy: openworkReconnectBusy(), - reconnectOpenworkServer, - openworkServerSettings: openworkServerSettings(), - openworkServerHostInfo: openworkServerHostInfo(), - shareRemoteAccessBusy: shareRemoteAccessBusy(), - shareRemoteAccessError: shareRemoteAccessError(), - saveShareRemoteAccess, - openworkServerCapabilities: openworkServerCapabilities(), - openworkServerDiagnostics: openworkServerDiagnostics(), - runtimeWorkspaceId: runtimeWorkspaceId(), - activeWorkspaceType: workspaceStore.selectedWorkspaceDisplay().workspaceType, - openworkAuditEntries: openworkAuditEntries(), - openworkAuditStatus: openworkAuditStatus(), - openworkAuditError: openworkAuditError(), - opencodeConnectStatus: opencodeConnectStatus(), - engineInfo: workspaceStore.engine(), - orchestratorStatus: orchestratorStatusState(), - opencodeRouterInfo: opencodeRouterInfoState(), - engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, - updateOpenworkServerSettings, - resetOpenworkServerSettings, - testOpenworkServerConnection, - canReloadWorkspace: canReloadWorkspace(), - reloadWorkspaceEngine, - reloadBusy: reloadBusy(), - reloadError: reloadError(), - selectedWorkspaceDisplay: selectedWorkspaceDisplay(), - workspaces: workspaceStore.workspaces(), - selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), - connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), - workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - selectWorkspace: workspaceStore.selectWorkspace, - switchWorkspace: workspaceStore.switchWorkspace, - testWorkspaceConnection: workspaceStore.testWorkspaceConnection, - recoverWorkspace: workspaceStore.recoverWorkspace, - openCreateWorkspace, - connectRemoteWorkspace: workspaceStore.createRemoteWorkspaceFlow, - openTeamBundle: bundlesStore.openTeamBundle, - exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig, - exportWorkspaceBusy: workspaceStore.exportingWorkspaceConfig(), - createWorkspaceOpen: workspaceStore.createWorkspaceOpen(), - setCreateWorkspaceOpen: workspaceStore.setCreateWorkspaceOpen, - createWorkspaceFlow: workspaceStore.createWorkspaceFlow, - pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, - workspaceSessionGroups: hydratedSidebarWorkspaceGroups(), - selectedSessionId: activeSessionId(), - openRenameWorkspace: workspaceStore.openRenameWorkspace, - editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, - forgetWorkspace: workspaceStore.forgetWorkspace, - schedulerPluginInstalled: schedulerPluginInstalled(), - selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - skillsAccessHint, - canInstallSkillCreator, - canUseDesktopTools, - pluginsAccessHint, - canEditPlugins, - canUseGlobalPluginScope, - suggestedPlugins: SUGGESTED_PLUGINS, - addPlugin, - createSessionInWorkspace, - createSessionAndOpen, - hideTitlebar: hideTitlebar(), - toggleHideTitlebar: () => setHideTitlebar((v) => !v), - updateAutoCheck: updateAutoCheck(), - toggleUpdateAutoCheck: () => setUpdateAutoCheck((v) => !v), - updateAutoDownload: updateAutoDownload(), - toggleUpdateAutoDownload: () => - setUpdateAutoDownload((v) => { - const next = !v; - if (next) { - setUpdateAutoCheck(true); - } - return next; - }), - updateStatus: updateStatus(), - updateEnv: updateEnv(), - appVersion: appVersion(), - checkForUpdates: () => checkForUpdates(), - downloadUpdate: () => downloadUpdate(), - installUpdateAndRestart, - anyActiveRuns: anyActiveRuns(), - engineSource: engineSource(), - setEngineSource, - engineCustomBinPath: engineCustomBinPath(), - setEngineCustomBinPath, - engineRuntime: engineRuntime(), - setEngineRuntime, - opencodeEnableExa: opencodeEnableExa(), - toggleOpencodeEnableExa: () => setOpencodeEnableExa((v) => !v), - isWindows: isWindowsPlatform(), - toggleDeveloperMode: () => setDeveloperMode((v) => !v), - developerMode: developerMode(), - stopHost, - restartLocalServer, - openResetModal, - resetModalBusy: resetModalBusy(), - onResetStartupPreference: () => { - clearStartupPreference(); - setStartupPreference(null); - setRememberStartupChoice(false); - }, - themeMode: themeMode(), - setThemeMode, - pendingPermissions: pendingPermissions(), - events: events(), - workspaceDebugEvents: workspaceStore.workspaceDebugEvents(), - sandboxCreateProgress: workspaceStore.sandboxCreateProgress(), - sandboxCreateProgressLast: workspaceStore.lastSandboxCreateProgress(), - clearWorkspaceDebugEvents: workspaceStore.clearWorkspaceDebugEvents, - safeStringify, - repairOpencodeCache, - cacheRepairBusy: cacheRepairBusy(), - cacheRepairResult: cacheRepairResult(), - cleanupOpenworkDockerContainers, - dockerCleanupBusy: dockerCleanupBusy(), - dockerCleanupResult: dockerCleanupResult(), - markOpencodeConfigReloadRequired, - resetAppConfigDefaults, - openDebugDeepLink: deepLinks.openDebugDeepLink, - language: currentLocale(), - setLanguage: setLocale, - }; - }; - - const sessionProps = () => ({ - providerAuthWorkerType: providerAuthWorkerType(), - selectedSessionId: activeSessionId(), - setView, - setSettingsTab, - toggleSettings: () => toggleSettingsView("general"), - selectedWorkspaceDisplay: selectedWorkspaceDisplay(), - selectedWorkspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - activeWorkspaceConfig: resolvedActiveWorkspaceConfig(), - workspaces: workspaceStore.workspaces(), - selectedWorkspaceId: workspaceStore.selectedWorkspaceId(), - connectingWorkspaceId: workspaceStore.connectingWorkspaceId(), - workspaceConnectionStateById: workspaceStore.workspaceConnectionStateById(), - selectWorkspace: workspaceStore.selectWorkspace, - switchWorkspace: workspaceStore.switchWorkspace, - testWorkspaceConnection: workspaceStore.testWorkspaceConnection, - recoverWorkspace: workspaceStore.recoverWorkspace, - editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings, - forgetWorkspace: workspaceStore.forgetWorkspace, - openCreateWorkspace, - exportWorkspaceConfig: workspaceStore.exportWorkspaceConfig, - exportWorkspaceBusy: workspaceStore.exportingWorkspaceConfig(), - clientConnected: Boolean(client()), - openworkServerStatus: openworkServerStatus(), - openworkServerClient: openworkServerClient(), - openworkServerDiagnostics: openworkServerDiagnostics(), - openworkServerSettings: openworkServerSettings(), - openworkServerHostInfo: openworkServerHostInfo(), - shareRemoteAccessBusy: shareRemoteAccessBusy(), - shareRemoteAccessError: shareRemoteAccessError(), - saveShareRemoteAccess, - runtimeWorkspaceId: runtimeWorkspaceId(), - engineInfo: workspaceStore.engine(), - engineDoctorVersion: workspaceStore.engineDoctorResult()?.version ?? null, - orchestratorStatus: orchestratorStatusState(), - opencodeRouterInfo: opencodeRouterInfoState(), - appVersion: appVersion(), - booting: booting(), - startupPhase: bootPhase(), - startupBranch: startupBranch(), - startupTrace: startupTrace(), - headerStatus: headerStatus(), - busyHint: busyHint(), - updateStatus: updateStatus(), - anyActiveRuns: anyActiveRuns(), - installUpdateAndRestart, - skills: skills(), - newTaskDisabled: newTaskDisabled(), - sidebarHydratedFromCache: sidebarHydratedFromCache(), - workspaceSessionGroups: hydratedSidebarWorkspaceGroups(), - openRenameWorkspace: workspaceStore.openRenameWorkspace, - messages: visibleMessages(), - getSessionById: sessionById, - getMessagesBySessionId: messagesBySessionId, - ensureSessionLoaded, - sessionLoadingById, - todos: activeTodos(), - busyLabel: busyLabel(), - developerMode: developerMode(), - sessionCompactionState: selectedSessionCompactionState(), - expandedStepIds: expandedStepIds(), - setExpandedStepIds: setExpandedStepIds, - workingFiles: activeWorkingFiles(), - busy: busy(), - prompt: prompt(), - setPrompt: setPrompt, - activePermission: activePermissionMemo(), - permissionReplyBusy: permissionReplyBusy(), - respondPermission: respondPermission, - respondPermissionAndRemember: respondPermissionAndRemember, - activeQuestion: activeQuestion(), - questionReplyBusy: questionReplyBusy(), - respondQuestion: respondQuestion, - safeStringify: safeStringify, - startProviderAuth: startProviderAuth, - completeProviderAuthOAuth: completeProviderAuthOAuth, - refreshProviders: refreshProviders, - submitProviderApiKey: submitProviderApiKey, - connectCloudProvider: connectCloudProvider, - openProviderAuthModal: openProviderAuthModal, - closeProviderAuthModal: closeProviderAuthModal, - providerAuthModalOpen: providerAuthModalOpen(), - providerAuthBusy: providerAuthBusy(), - providerAuthError: providerAuthError(), - providerAuthMethods: providerAuthMethods(), - providerAuthProviders: providerAuthProviders(), - providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providers: visibleProviders(), - providerConnectedIds: visibleProviderConnectedIds(), - sessionStatusById: activeSessionStatusById(), - hasEarlierMessages: selectedSessionHasEarlierMessages(), - loadingEarlierMessages: selectedSessionLoadingEarlierMessages(), - loadEarlierMessages, - sessionErrorTurns: selectedSessionErrorTurns(), - sessionStatus: selectedSessionStatus(), - error: error(), - }); - - const reactSessionRuntimeEnabled = createMemo(() => reactSessionEnabled()); - const reactSessionRuntimeBaseUrl = createMemo(() => { - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - const baseUrl = openworkServerClient()?.baseUrl?.trim() ?? ""; - if (!workspaceId || !baseUrl) return ""; - const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl; - return `${mounted.replace(/\/+$/, "")}/opencode`; - }); - const reactSessionRuntimeToken = createMemo( - () => openworkServerClient()?.token?.trim() || openworkServerSettings().token?.trim() || "", - ); - const showReactSessionRuntime = createMemo( - () => - reactSessionRuntimeEnabled() && - openworkServerStatus() === "connected" && - Boolean(runtimeWorkspaceId()?.trim() && reactSessionRuntimeBaseUrl() && reactSessionRuntimeToken()), - ); - - const settingsTabs = new Set([ - "general", - "den", - "automations", - "skills", - "extensions", - "messaging", - "advanced", - "appearance", - "updates", - "recovery", - "debug", - ]); - - const resolveSettingsTab = (value?: string | null) => { - const normalized = value?.trim().toLowerCase() ?? ""; - if (settingsTabs.has(normalized as SettingsTab)) { - return normalized as SettingsTab; - } - return "general"; - }; - - createEffect(() => { - const rawPath = location.pathname.trim(); - const path = rawPath.toLowerCase(); - - if (forceSigninEnabled()) { - if (denAuth.status() === "checking") { - return; - } - - if (!denAuth.isSignedIn()) { - if (path !== "/signin") { - navigate("/signin", { replace: true }); - } - return; - } - - if (path === "/signin") { - navigate("/session", { replace: true }); - return; - } - } else if (path === "/signin") { - navigate("/session", { replace: true }); - return; - } - - if (path === "" || path === "/") { - navigate("/session", { replace: true }); - return; - } - - if (path.startsWith("/settings")) { - const [, , tabSegment] = path.split("/"); - const resolvedTab = resolveSettingsTab(tabSegment); - - if (resolvedTab !== settingsTab()) { - setSettingsTabState(resolvedTab); - } - if (!tabSegment || tabSegment !== resolvedTab) { - goToSettings(resolvedTab, { replace: true }); - } - return; - } - - if (path.startsWith("/session")) { - const [, , sessionSegment] = rawPath.split("/"); - const id = (sessionSegment ?? "").trim(); - - if (!id) { - if (selectedSessionId()) { - workspaceStore.clearSelectedSessionSurface(); - } - return; - } - - // If the URL points at a session that no longer exists (e.g. after deletion), - // route back to /session so the app can fall back safely. - const pendingInitialSelection = pendingInitialSessionSelection(); - const selectedWorkspaceRoot = normalizeDirectoryPath(workspaceStore.selectedWorkspaceRoot().trim()); - const matchingSession = sessions().find((session) => session.id === id) ?? null; - const hasMatchingSessionInScope = matchingSession - ? !selectedWorkspaceRoot || normalizeDirectoryPath(matchingSession.directory) === selectedWorkspaceRoot - : false; - if ( - sessionsLoaded() && - !pendingInitialSelection && - shouldRedirectMissingSessionAfterScopedLoad({ - loadedScopeRoot: loadedSessionScopeRoot(), - workspaceRoot: workspaceStore.selectedWorkspaceRoot().trim(), - hasMatchingSession: hasMatchingSessionInScope, - }) - ) { - if (selectedSessionId() === id) { - setSelectedSessionId(null); - } - navigate("/session", { replace: true }); - return; - } - - if (selectedSessionId() !== id) { - setSelectedSessionId(id); - void selectSession(id); - } - return; - } - - const fallback = activeSessionId(); - if (fallback) { - goToSession(fallback, { replace: true }); - return; - } - navigate("/session", { replace: true }); - }); - - return ( - - - - - - - - - - - {null} - - - - - - - - - - - - - - setResetModalOpen(false)} - onConfirm={confirmReset} - onTextChange={setResetModalText} - /> - - 0} - activeSessions={activeReloadBlockingSessions()} - isRemoteWorkspace={selectedWorkspaceDisplay().workspaceType === "remote"} - onForceStopSession={(sessionID) => abortSession(sessionID)} - onReloadEngine={() => reloadWorkspaceEngine()} - /> - - { - void bundlesStore.openCreateWorkspaceFromChoice(); - }} - onSelectWorker={(workspaceId) => { - void bundlesStore.importBundleIntoExistingWorkspace(workspaceId); - }} - /> - - { - const warning = bundlesStore.untrustedBundleWarning(); - const actualOrigin = warning?.actualOrigin?.trim() || "an unknown origin"; - const configuredOrigin = warning?.configuredOrigin?.trim() || "the configured OpenWork share service"; - return `This link points to ${actualOrigin}, but OpenWork only auto-imports bundles from ${configuredOrigin}. Untrusted bundles can contain malicious instructions or settings. Only continue if you trust the sender and expect this import.`; - })()} - confirmLabel="Import anyway" - cancelLabel="Cancel" - variant="warning" - onConfirm={() => { - void bundlesStore.confirmUntrustedBundleWarning(); - }} - onCancel={bundlesStore.dismissUntrustedBundleWarning} - /> - - { - bundlesStore.clearBundleStartRequest(); - }} - onPickFolder={workspaceStore.pickWorkspaceFolder} - onConfirm={(folder) => { - void bundlesStore.startWorkspaceFromBundle(folder); - }} - /> - - { - workspaceStore.setCreateWorkspaceOpen(false); - workspaceStore.clearSandboxCreateProgress?.(); - bundlesStore.clearCreateWorkspaceRequest(); - }} - onPickFolder={workspaceStore.pickWorkspaceFolder} - onImportConfig={isTauriRuntime() ? workspaceStore.importWorkspaceConfig : undefined} - importingConfig={workspaceStore.importingWorkspaceConfig()} - defaultPreset={bundlesStore.createWorkspaceDefaultPreset()} - onConfirmRemote={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} - onConfirmTemplate={(template, preset, folder) => - bundlesStore.startWorkspaceFromTeamTemplate({ - name: template.name, - templateData: template.templateData, - folder, - preset, - }) - } - onConfirm={bundlesStore.handleCreateWorkspaceConfirm} - onConfirmWorker={ - isTauriRuntime() - ? bundlesStore.handleCreateSandboxConfirm - : undefined - } - workerDisabled={(() => { - if (!isTauriRuntime()) return true; - if (workspaceStore.sandboxDoctorBusy?.()) return true; - const doctor = workspaceStore.sandboxDoctorResult?.(); - if (!doctor) return false; - return !doctor?.ready; - })()} - workerDisabledReason={(() => { - if (!isTauriRuntime()) return t("app.error.tauri_required", currentLocale()); - if (workspaceStore.sandboxDoctorBusy?.()) { - return t("dashboard.sandbox_checking_docker", currentLocale()); - } - const doctor = workspaceStore.sandboxDoctorResult?.(); - if (!doctor || doctor.ready) return null; - const message = doctor?.error?.trim(); - return message || t("dashboard.sandbox_get_ready_desc", currentLocale()); - })()} - workerCtaLabel={t("dashboard.sandbox_get_ready_action", currentLocale())} - workerCtaDescription={t("dashboard.sandbox_get_ready_desc", currentLocale())} - onWorkerCta={async () => { - const url = "https://www.docker.com/products/docker-desktop/"; - if (isTauriRuntime()) { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl(url); - } else { - window.open(url, "_blank", "noopener,noreferrer"); - } - }} - workerRetryLabel={t("common.retry", currentLocale())} - workerDebugLines={(() => { - const doctor = workspaceStore.sandboxDoctorResult?.(); - const lines: string[] = []; - if (!doctor?.debug) return lines; - const selected = doctor.debug.selectedBin?.trim(); - if (selected) lines.push(`selected: ${selected}`); - if (doctor.debug.candidates?.length) { - lines.push(`candidates: ${doctor.debug.candidates.join(", ")}`); - } - if (doctor.debug.versionCommand) { - const cmd = doctor.debug.versionCommand; - lines.push(`docker --version exit=${cmd.status}`); - if (cmd.stderr?.trim()) lines.push(`docker --version stderr: ${cmd.stderr.trim()}`); - } - if (doctor.debug.infoCommand) { - const cmd = doctor.debug.infoCommand; - lines.push(`docker info exit=${cmd.status}`); - if (cmd.stderr?.trim()) lines.push(`docker info stderr: ${cmd.stderr.trim()}`); - } - return lines; - })()} - onWorkerRetry={() => { - void workspaceStore.refreshSandboxDoctor?.(); - }} - workerSubmitting={workspaceStore.sandboxPreflightBusy?.() ?? false} - localDisabled={!isTauriRuntime()} - localDisabledReason={ - !isTauriRuntime() - ? t("app.local_disabled_reason") - : null - } - remoteSubmitting={busy() && busyLabel() === "status.connecting"} - remoteError={busyLabel() === "status.connecting" ? error() : null} - submitting={(() => { - const phase = workspaceStore.sandboxCreatePhase?.() ?? "idle"; - if (phase === "provisioning" || phase === "finalizing") return true; - return busy() && busyLabel() === "status.creating_workspace"; - })()} - submittingProgress={workspaceStore.sandboxCreateProgress?.() ?? null} - /> - - setRestrictionNotice(null)} - title={restrictionNotice()?.title ?? "Restriction"} - message={restrictionNotice()?.message ?? ""} - /> - - { - const request = bundlesStore.skillDestinationRequest(); - if (!request) return null; - return { - name: request.bundle.name, - description: request.bundle.description ?? null, - trigger: request.bundle.trigger ?? null, - }; - })()} - workspaces={bundlesStore.skillDestinationWorkspaces()} - selectedWorkspaceId={workspaceStore.selectedWorkspaceId()} - busyWorkspaceId={bundlesStore.skillDestinationBusyId()} - onClose={() => { - bundlesStore.clearSkillDestinationRequest(); - }} - onSubmitWorkspace={bundlesStore.importSkillIntoWorkspace} - onCreateWorker={ - isTauriRuntime() - ? bundlesStore.openCreateWorkspaceFromSkillDestination - : undefined - } - onConnectRemote={() => { - bundlesStore.openRemoteConnectFromSkillDestination(); - }} - /> - - { - workspaceStore.setCreateRemoteWorkspaceOpen(false); - deepLinks.clearDeepLinkRemoteWorkspaceDefaults(); - }} - onConfirm={(input) => workspaceStore.createRemoteWorkspaceFlow(input)} - initialValues={deepLinks.deepLinkRemoteWorkspaceDefaults() ?? undefined} - submitting={ - busy() && - (busyLabel() === "status.creating_workspace" || busyLabel() === "status.connecting") - } - /> - - 0 ? t("app.reload_stop_tasks") : t("app.reload_now")} - dismissLabel={t("app.reload_later")} - reloadBusy={reloadBusy()} - canReload={canReloadWorkspace()} - hasActiveRuns={activeReloadBlockingSessions().length > 0} - onReload={() => { - void (activeReloadBlockingSessions().length > 0 - ? forceStopActiveSessionsAndReload() - : reloadWorkspaceEngine()); - }} - onDismissReload={clearReloadRequired} - /> - - 0 && !workspaceStore.renameWorkspaceBusy()} - onClose={workspaceStore.closeRenameWorkspace} - onSave={workspaceStore.saveRenameWorkspace} - onTitleChange={workspaceStore.setRenameWorkspaceName} - /> - - { - void workspaceStore.saveWorkspaceConnectionSettings(input); - }} - initialValues={workspaceStore.editRemoteWorkspaceDefaults() ?? undefined} - submitting={busy() && busyLabel() === "status.connecting"} - error={workspaceStore.editRemoteWorkspaceError()} - title={t("dashboard.edit_remote_workspace_title", currentLocale())} - subtitle={t("dashboard.edit_remote_workspace_subtitle", currentLocale())} - confirmLabel={t("dashboard.edit_remote_workspace_confirm", currentLocale())} - /> - - - - - - - - ); -} diff --git a/apps/app/src/app/automations/provider.tsx b/apps/app/src/app/automations/provider.tsx deleted file mode 100644 index e59cf719..00000000 --- a/apps/app/src/app/automations/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { AutomationsStore } from "../context/automations"; - -const AutomationsContext = createContext(); - -export function AutomationsProvider(props: ParentProps<{ store: AutomationsStore }>) { - return ( - - {props.children} - - ); -} - -export function useAutomations() { - const context = useContext(AutomationsContext); - if (!context) { - throw new Error("useAutomations must be used within an AutomationsProvider"); - } - return context; -} diff --git a/apps/app/src/app/bundles/import-modal.tsx b/apps/app/src/app/bundles/import-modal.tsx deleted file mode 100644 index 77bbce09..00000000 --- a/apps/app/src/app/bundles/import-modal.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import { Boxes, ChevronDown, ChevronRight, Plus, Sparkles, X } from "lucide-solid"; - -import type { BundleWorkerOption } from "./types"; - -export default function BundleImportModal(props: { - open: boolean; - title: string; - description: string; - items: string[]; - workers: BundleWorkerOption[]; - busy?: boolean; - error?: string | null; - onClose: () => void; - onCreateNewWorker: () => void; - onSelectWorker: (workspaceId: string) => void; -}) { - const [showWorkers, setShowWorkers] = createSignal(false); - - createEffect(() => { - if (!props.open) return; - setShowWorkers(false); - }); - - createEffect(() => { - if (!props.open) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - props.onClose(); - }; - window.addEventListener("keydown", handleKeyDown); - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); - }); - - const visibleItems = createMemo(() => props.items.filter(Boolean).slice(0, 4)); - const hiddenItemCount = createMemo(() => Math.max(0, props.items.filter(Boolean).length - visibleItems().length)); - const busy = () => Boolean(props.busy); - - return ( - -
-
-
-
-
-
- -
-
-

{props.title}

-

{props.description}

-
-
- -
- - 0}> -
- - {(item) => ( - - {item} - - )} - - 0}> - - +{hiddenItemCount()} more - - -
-
-
- -
- -
{props.error}
-
- - - -
- - - -
- 0} - fallback={
No configured workers are available yet. Create a new worker to import this bundle.
} - > - - {(worker) => { - const disabledReason = () => worker.disabledReason?.trim() ?? ""; - const disabled = () => Boolean(disabledReason()) || busy(); - return ( - - ); - }} - -
-
-
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/index.ts b/apps/app/src/app/bundles/index.ts index 51b7b054..002cd2ab 100644 --- a/apps/app/src/app/bundles/index.ts +++ b/apps/app/src/app/bundles/index.ts @@ -2,5 +2,4 @@ export * from "./apply"; export * from "./publish"; export * from "./schema"; export * from "./sources"; -export * from "./store"; export * from "./types"; diff --git a/apps/app/src/app/bundles/skill-destination-modal.tsx b/apps/app/src/app/bundles/skill-destination-modal.tsx deleted file mode 100644 index 897d7c14..00000000 --- a/apps/app/src/app/bundles/skill-destination-modal.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; - -import { CheckCircle2, Folder, FolderPlus, Globe, Loader2, Sparkles, X } from "lucide-solid"; -import type { WorkspaceInfo } from "../lib/tauri"; -import { t, currentLocale } from "../../i18n"; -import { isSandboxWorkspace } from "../utils"; - -import Button from "../components/button"; - -type SkillSummary = { - name: string; - description?: string | null; - trigger?: string | null; -}; - -export default function SkillDestinationModal(props: { - open: boolean; - skill: SkillSummary | null; - workspaces: WorkspaceInfo[]; - selectedWorkspaceId?: string | null; - busyWorkspaceId?: string | null; - onClose: () => void; - onSubmitWorkspace: (workspaceId: string) => void | Promise; - onCreateWorker?: () => void; - onConnectRemote?: () => void; -}) { - const translate = (key: string) => t(key, currentLocale()); - const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal(null); - - const displayName = (workspace: WorkspaceInfo) => - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - workspace.directory?.trim() || - workspace.path?.trim() || - workspace.baseUrl?.trim() || - "Worker"; - - const subtitle = (workspace: WorkspaceInfo) => { - if (workspace.workspaceType === "local") { - return workspace.path?.trim() || translate("share_skill_destination.local_badge"); - } - return ( - workspace.directory?.trim() || - workspace.openworkHostUrl?.trim() || - workspace.baseUrl?.trim() || - workspace.path?.trim() || - translate("share_skill_destination.remote_badge") - ); - }; - - const workspaceBadge = (workspace: WorkspaceInfo) => { - if (isSandboxWorkspace(workspace)) { - return translate("share_skill_destination.sandbox_badge"); - } - if (workspace.workspaceType === "remote") { - return translate("share_skill_destination.remote_badge"); - } - return translate("share_skill_destination.local_badge"); - }; - - const footerBusy = () => Boolean(props.busyWorkspaceId?.trim()); - const selectedWorkspace = createMemo(() => props.workspaces.find((workspace) => workspace.id === selectedWorkspaceId()) ?? null); - - createEffect(() => { - if (!props.open) return; - const activeMatch = props.workspaces.find((workspace) => workspace.id === props.selectedWorkspaceId) ?? props.workspaces[0] ?? null; - setSelectedWorkspaceId(activeMatch?.id ?? null); - }); - - const submitSelectedWorkspace = () => { - const workspaceId = selectedWorkspaceId()?.trim(); - if (!workspaceId || footerBusy()) return; - void props.onSubmitWorkspace(workspaceId); - }; - - const workspaceCircleClass = (workspace: WorkspaceInfo, selected: boolean) => { - if (selected) { - return "bg-indigo-7/15 text-indigo-11 border border-indigo-7/30"; - } - if (isSandboxWorkspace(workspace)) { - return "bg-indigo-7/10 text-indigo-11 border border-indigo-7/20"; - } - if (workspace.workspaceType === "remote") { - return "bg-sky-7/10 text-sky-11 border border-sky-7/20"; - } - return "bg-amber-7/10 text-amber-11 border border-amber-7/20"; - }; - - return ( - -
-
-
-
-
-
- - {translate("share_skill_destination.skill_label")} -
-
-
-
- -
-
-
- {translate("share_skill_destination.skill_label")} -
-

- {props.skill?.name ?? translate("share_skill_destination.fallback_skill_name")} -

- -

{props.skill?.description?.trim()}

-
- -
- {translate("share_skill_destination.trigger_label")} - {props.skill?.trigger?.trim()} -
-
-
-
-
-
-

{translate("share_skill_destination.title")}

-

{translate("share_skill_destination.subtitle")}

-
-
- - -
-
- -
-
-
-
{translate("share_skill_destination.existing_workers")}
- 0}> - {props.workspaces.length} - -
- - 0} - fallback={ -
- {translate("share_skill_destination.no_workers")} -
- } - > -
- - {(workspace) => { - const isActive = () => workspace.id === props.selectedWorkspaceId; - const isSelected = () => workspace.id === selectedWorkspaceId(); - const isBusy = () => workspace.id === props.busyWorkspaceId; - const WorkspaceIcon = () => (workspace.workspaceType === "remote" ? : ); - - return ( - - ); - }} - -
-
-
- - -
-
{translate("share_skill_destination.more_options")}
-
- - - - - - - -
-
-
-
- -
-
- - {(workspace) => ( -
- {displayName(workspace())} - · - {subtitle(workspace())} -
- )} -
- -
- - -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/start-modal.tsx b/apps/app/src/app/bundles/start-modal.tsx deleted file mode 100644 index fdf9aced..00000000 --- a/apps/app/src/app/bundles/start-modal.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import { FolderPlus, Loader2, Rocket, X } from "lucide-solid"; - -import Button from "../components/button"; - -export default function BundleStartModal(props: { - open: boolean; - templateName: string; - description?: string | null; - items?: string[]; - busy?: boolean; - onClose: () => void; - onPickFolder: () => Promise; - onConfirm: (folder: string | null) => void | Promise; -}) { - let pickFolderRef: HTMLButtonElement | undefined; - const [selectedFolder, setSelectedFolder] = createSignal(null); - const [pickingFolder, setPickingFolder] = createSignal(false); - - createEffect(() => { - if (!props.open) return; - setSelectedFolder(null); - requestAnimationFrame(() => pickFolderRef?.focus()); - }); - - createEffect(() => { - if (!props.open) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - if (props.busy) return; - props.onClose(); - }; - window.addEventListener("keydown", handleKeyDown); - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); - }); - - const visibleItems = createMemo(() => (props.items ?? []).filter(Boolean).slice(0, 4)); - const hiddenItemCount = createMemo(() => Math.max(0, (props.items ?? []).filter(Boolean).length - visibleItems().length)); - const canSubmit = createMemo(() => Boolean(selectedFolder()?.trim()) && !props.busy && !pickingFolder()); - - const handlePickFolder = async () => { - if (pickingFolder() || props.busy) return; - setPickingFolder(true); - try { - const next = await props.onPickFolder(); - if (next) setSelectedFolder(next); - } finally { - setPickingFolder(false); - } - }; - - return ( - -
-
-
-
-
-
- -
-
-

Start with {props.templateName}

-

- {props.description?.trim() || "Pick a folder and OpenWork will create a workspace from this template."} -

-
-
- -
- - 0}> -
- - {(item) => ( - - {item} - - )} - - 0}> - - +{hiddenItemCount()} more - - -
-
-
- -
-
-
Workspace folder
-

- Choose where this template should live. OpenWork will create the workspace and bring in the template automatically. -

-
- No folder selected yet.}> - {selectedFolder()} - -
-
- -
-
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/bundles/store.ts b/apps/app/src/app/bundles/store.ts deleted file mode 100644 index 4b26324e..00000000 --- a/apps/app/src/app/bundles/store.ts +++ /dev/null @@ -1,885 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from "solid-js"; - -import type { - ReloadReason, - ReloadTrigger, - SettingsTab, - View, - WorkspacePreset, -} from "../types"; -import { normalizeOpenworkServerUrl, parseOpenworkWorkspaceIdFromUrl } from "../lib/openwork-server"; -import { t } from "../../i18n"; -import { isSandboxWorkspace, isTauriRuntime, safeStringify, addOpencodeCacheHint } from "../utils"; -import type { WorkspaceStore } from "../context/workspace"; -import type { StartupPreference } from "../types"; -import type { OpenworkServerStore } from "../connections/openwork-server-store"; -import { - buildImportPayloadFromBundle, - describeWorkspaceForBundleToasts, - isBundleImportWorkspace, - resolveBundleImportTargetForWorkspace, -} from "./apply"; -import { defaultPresetFromWorkspaceProfileBundle, describeBundleImport, parseBundlePayload } from "./schema"; -import { fetchBundle, parseBundleDeepLink } from "./sources"; -import { describeBundleUrlTrust } from "./url-policy"; -import type { - BundleCreateWorkspaceRequest, - BundleImportChoice, - BundleImportSummary, - BundleImportTarget, - BundleRequest, - BundleStartRequest, - BundleWorkerOption, - BundleV1, - SkillDestinationRequest, - WorkspaceProfileBundleV1, -} from "./types"; -import type { AppStatusToastInput } from "../shell/status-toasts"; - -type BundleProcessResult = - | { mode: "choice"; bundle: BundleV1 } - | { mode: "start_modal"; bundle: BundleV1 } - | { mode: "blocked_import_current"; bundle: BundleV1 } - | { mode: "blocked_new_worker"; bundle: BundleV1 } - | { mode: "untrusted_warning" } - | { mode: "imported"; bundle: BundleV1 }; - -type UntrustedBundleWarning = { - request: BundleRequest; - actualOrigin: string | null; - configuredOrigin: string | null; -}; - -export type BundlesStore = ReturnType; - -export function createBundlesStore(options: { - booting: Accessor; - startupPreference: Accessor; - openworkServer: OpenworkServerStore; - runtimeWorkspaceId: Accessor; - workspaceStore: WorkspaceStore; - setError: (value: string | null) => void; - error: Accessor; - setView: (next: View, sessionId?: string) => void; - setSettingsTab: (nextTab: SettingsTab) => void; - refreshActiveWorkspaceServerConfig: (workspaceId: string) => Promise; - refreshSkills: (input?: { force?: boolean }) => Promise; - refreshHubSkills: (input?: { force?: boolean }) => Promise; - markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void; - showStatusToast: (toast: AppStatusToastInput) => void; -}) { - const [pendingBundleRequest, setPendingBundleRequest] = createSignal(null); - const [bundleStartRequest, setBundleStartRequest] = createSignal(null); - const [bundleStartBusy, setBundleStartBusy] = createSignal(false); - const [createWorkspaceRequest, setCreateWorkspaceRequest] = createSignal(null); - const [skillDestinationRequest, setSkillDestinationRequest] = createSignal(null); - const [skillDestinationBusyId, setSkillDestinationBusyId] = createSignal(null); - const [bundleImportChoice, setBundleImportChoice] = createSignal(null); - const [bundleImportBusy, setBundleImportBusy] = createSignal(false); - const [bundleImportError, setBundleImportError] = createSignal(null); - const [bundleNoticeShown, setBundleNoticeShown] = createSignal(false); - const [untrustedBundleWarning, setUntrustedBundleWarning] = createSignal(null); - - const showSkillSuccessToast = (toast: { title: string; description: string }) => { - options.showStatusToast({ - ...toast, - tone: "success", - durationMs: 4200, - }); - }; - - const resetInteractiveBundleState = () => { - setSkillDestinationRequest(null); - setSkillDestinationBusyId(null); - setBundleImportChoice(null); - setBundleStartRequest(null); - setCreateWorkspaceRequest(null); - setBundleImportError(null); - setBundleNoticeShown(false); - setUntrustedBundleWarning(null); - }; - - const maybeWarnAboutUntrustedBundle = (request: BundleRequest, options?: { allowUntrustedClientFetch?: boolean }) => { - const rawUrl = request.bundleUrl?.trim() ?? ""; - if (!rawUrl || options?.allowUntrustedClientFetch) return false; - const trust = describeBundleUrlTrust(rawUrl); - if (trust.trusted) return false; - setUntrustedBundleWarning({ - request, - actualOrigin: trust.actualOrigin, - configuredOrigin: trust.configuredOrigin, - }); - return true; - }; - - const resolveBundleWorkerTarget = () => { - const pref = options.startupPreference(); - const hostInfo = options.openworkServer.openworkServerHostInfo(); - const settings = options.openworkServer.openworkServerSettings(); - - const localHostUrl = normalizeOpenworkServerUrl(hostInfo?.baseUrl ?? "") ?? ""; - const localToken = hostInfo?.clientToken?.trim() ?? ""; - const serverHostUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; - const serverToken = settings.token?.trim() ?? ""; - - if (pref === "server") { - return { - hostUrl: serverHostUrl || localHostUrl, - token: serverToken || localToken, - }; - } - - if (pref === "local") { - return { - hostUrl: localHostUrl || serverHostUrl, - token: localToken || serverToken, - }; - } - - if (localHostUrl) { - return { - hostUrl: localHostUrl, - token: localToken || serverToken, - }; - } - - return { - hostUrl: serverHostUrl, - token: serverToken || localToken, - }; - }; - - const resolveActiveBundleImportTarget = (): BundleImportTarget => { - const active = options.workspaceStore.selectedWorkspaceDisplay(); - if (active.workspaceType === "local") { - return { localRoot: options.workspaceStore.selectedWorkspaceRoot().trim() }; - } - - return { - workspaceId: - active.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || - null, - directoryHint: active.directory?.trim() || active.path?.trim() || null, - }; - }; - - const waitForBundleImportTarget = async (timeoutMs = 20_000, target?: BundleImportTarget) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const client = options.openworkServer.openworkServerClient(); - if (client && options.openworkServer.openworkServerStatus() === "connected") { - if (target?.workspaceId?.trim() || target?.localRoot?.trim() || target?.directoryHint?.trim()) { - try { - const matchId = await options.workspaceStore.ensureRuntimeWorkspaceId({ - workspaceId: target.workspaceId, - localRoot: target.localRoot, - directoryHint: target.directoryHint, - strictMatch: true, - }); - if (matchId) { - return { client, workspaceId: matchId }; - } - } catch { - // ignore and keep polling - } - } else { - const workspaceId = options.runtimeWorkspaceId(); - if (workspaceId) { - return { client, workspaceId }; - } - } - } - await new Promise((resolve) => { - window.setTimeout(resolve, 200); - }); - } - throw new Error("OpenWork worker is not ready yet."); - }; - - const importBundlePayload = async (bundle: BundleV1, target?: BundleImportTarget) => { - const { client, workspaceId } = await waitForBundleImportTarget(20_000, target); - const { payload, importedSkillsCount } = buildImportPayloadFromBundle(bundle); - await client.importWorkspace(workspaceId, payload); - await options.refreshActiveWorkspaceServerConfig(workspaceId); - await options.refreshSkills({ force: true }); - await options.refreshHubSkills({ force: true }); - if (importedSkillsCount > 0) { - options.markReloadRequired("skills", { - type: "skill", - name: bundle.name?.trim() || undefined, - action: "added", - }); - } - }; - - const importBundleIntoActiveWorker = async ( - request: BundleRequest, - target?: BundleImportTarget, - bundleOverride?: BundleV1, - importOptions?: { allowUntrustedClientFetch?: boolean }, - ) => { - try { - const bundle = - bundleOverride ?? - (await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { - forceClientFetch: importOptions?.allowUntrustedClientFetch, - })); - await importBundlePayload(bundle, target); - options.setError(null); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - }; - - const createWorkerForBundle = async (request: BundleRequest, bundle: BundleV1) => { - const target = resolveBundleWorkerTarget(); - const hostUrl = target.hostUrl.trim(); - const token = target.token.trim(); - if (!hostUrl || !token) { - throw new Error("Bundle link detected. Configure an OpenWork worker host and token, then open the link again."); - } - - const label = (request.label?.trim() || bundle.name?.trim() || t("app.shared_setup")).slice(0, 80); - const ok = await options.workspaceStore.createRemoteWorkspaceFlow({ - openworkHostUrl: hostUrl, - openworkToken: token, - directory: null, - displayName: label, - manageBusy: false, - closeModal: false, - }); - - if (!ok) { - throw new Error("Failed to create a worker from this bundle."); - } - }; - - const startWorkspaceFromBundle = async (folder: string | null) => { - const request = bundleStartRequest(); - if (!request || bundleStartBusy()) return false; - - setBundleStartBusy(true); - try { - const ok = await options.workspaceStore.createWorkspaceFlow(request.defaultPreset, folder); - if (!ok) return false; - - const imported = await importBundleIntoActiveWorker( - request.request, - { - localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), - }, - request.bundle, - ); - if (!imported) return false; - - setBundleStartRequest(null); - options.setError(null); - return true; - } finally { - setBundleStartBusy(false); - } - }; - - const createWorkspaceFromBundle = async ( - bundle: WorkspaceProfileBundleV1, - folder: string | null, - defaultPreset = defaultPresetFromWorkspaceProfileBundle(bundle), - ) => { - const request: BundleRequest = { - intent: "new_worker", - source: "team-template", - label: bundle.name, - }; - - const ok = await options.workspaceStore.createWorkspaceFlow(defaultPreset, folder); - if (!ok) return false; - - return importBundleIntoActiveWorker( - request, - { - localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), - }, - bundle, - ); - }; - - const importSkillIntoWorkspace = async (workspaceId: string) => { - if (skillDestinationBusyId()) return; - const destination = skillDestinationRequest(); - if (!destination) return; - - const workspace = options.workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!isBundleImportWorkspace(workspace)) { - options.setError("This worker cannot accept imported skills yet."); - return; - } - - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setSkillDestinationBusyId(workspaceId); - - try { - const ok = await options.workspaceStore.activateWorkspace(workspaceId); - if (!ok) return; - - const imported = await importBundleIntoActiveWorker( - destination.request, - resolveBundleImportTargetForWorkspace(workspace), - destination.bundle, - ); - if (!imported) return; - - showSkillSuccessToast({ - title: t("app.skill_added"), - description: `Added '${destination.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(workspace)}.`, - }); - setSkillDestinationRequest(null); - setCreateWorkspaceRequest(null); - setBundleNoticeShown(false); - } finally { - setSkillDestinationBusyId(null); - } - }; - - const processBundleRequest = async ( - request: BundleRequest, - processOptions?: { allowUntrustedClientFetch?: boolean }, - ): Promise => { - if (maybeWarnAboutUntrustedBundle(request, processOptions)) { - return { mode: "untrusted_warning" }; - } - - const bundle = await fetchBundle(request.bundleUrl?.trim() ?? "", options.openworkServer.openworkServerClient(), { - forceClientFetch: processOptions?.allowUntrustedClientFetch, - }); - - if (bundle.type === "skill") { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setSkillDestinationRequest({ request, bundle }); - return { mode: "choice", bundle }; - } - - if (bundle.type === "skills-set") { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setBundleImportChoice({ request, bundle }); - return { mode: "choice", bundle }; - } - - if (request.intent === "new_worker" && isTauriRuntime()) { - options.setView("settings"); - options.setSettingsTab("skills"); - options.setError(null); - setCreateWorkspaceRequest(null); - setBundleImportChoice(null); - setBundleStartRequest({ - request, - bundle, - defaultPreset: defaultPresetFromWorkspaceProfileBundle(bundle), - }); - return { mode: "start_modal", bundle }; - } - - if (request.intent === "import_current") { - const client = options.openworkServer.openworkServerClient(); - const connected = options.openworkServer.openworkServerStatus() === "connected"; - const target = resolveActiveBundleImportTarget(); - const hasTargetHint = Boolean(target.workspaceId?.trim() || target.localRoot?.trim() || target.directoryHint?.trim()); - if (!client || !connected || !hasTargetHint) { - if (!bundleNoticeShown()) { - setBundleNoticeShown(true); - options.setError("Bundle link detected. Connect to a writable OpenWork worker to import this bundle."); - } - return { mode: "blocked_import_current", bundle }; - } - } else { - const target = resolveBundleWorkerTarget(); - if (!target.hostUrl.trim() || !target.token.trim()) { - if (!bundleNoticeShown()) { - setBundleNoticeShown(true); - options.setError("Bundle link detected. Configure an OpenWork host and token to create a new worker."); - } - return { mode: "blocked_new_worker", bundle }; - } - } - - if (request.intent === "new_worker") { - await createWorkerForBundle(request, bundle); - } - - await importBundlePayload(bundle, resolveActiveBundleImportTarget()); - options.setError(null); - return { mode: "imported", bundle }; - }; - - createEffect(() => { - const request = pendingBundleRequest(); - if (!request || options.booting()) { - return; - } - - if (untrack(bundleImportBusy)) { - return; - } - - let cancelled = false; - setBundleImportBusy(true); - - void (async () => { - try { - await processBundleRequest(request); - if (cancelled) return; - } catch (error) { - if (!cancelled) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } - } finally { - if (!cancelled) { - const nextPendingRequest = pendingBundleRequest(); - const shouldClearPendingRequest = nextPendingRequest === request; - setBundleImportBusy(false); - if (shouldClearPendingRequest) { - setPendingBundleRequest(null); - setBundleNoticeShown(false); - } else if (nextPendingRequest) { - setPendingBundleRequest({ ...nextPendingRequest }); - } - } - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - const queueBundleLink = (rawUrl: string): boolean => { - const parsed = parseBundleDeepLink(rawUrl); - if (!parsed) { - return false; - } - setPendingBundleRequest(parsed); - resetInteractiveBundleState(); - return true; - }; - - const openDebugBundleRequest = async (request: BundleRequest): Promise<{ ok: boolean; message: string }> => { - setPendingBundleRequest(null); - setBundleNoticeShown(false); - resetInteractiveBundleState(); - options.setError(null); - - try { - setBundleImportBusy(true); - const result = await processBundleRequest(request); - switch (result.mode) { - case "choice": - return { ok: true, message: "Opened the bundle import chooser." }; - case "start_modal": - return { ok: true, message: "Opened the template start flow." }; - case "blocked_import_current": - case "blocked_new_worker": - return { ok: false, message: options.error() || "The bundle needs more worker setup before it can open." }; - case "untrusted_warning": - return { ok: false, message: "Showed a security warning for an untrusted bundle link." }; - case "imported": - return { ok: true, message: "Imported the bundle into the current worker." }; - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - options.setError(friendly); - return { ok: false, message: friendly }; - } finally { - setBundleImportBusy(false); - } - }; - - const closeBundleImportChoice = () => { - if (bundleImportBusy()) return; - setBundleImportChoice(null); - setBundleImportError(null); - }; - - const dismissUntrustedBundleWarning = () => { - if (bundleImportBusy()) return; - setUntrustedBundleWarning(null); - }; - - const confirmUntrustedBundleWarning = async () => { - const warning = untrustedBundleWarning(); - if (!warning || bundleImportBusy()) return; - setUntrustedBundleWarning(null); - setBundleImportError(null); - options.setError(null); - - try { - setBundleImportBusy(true); - await processBundleRequest(warning.request, { allowUntrustedClientFetch: true }); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - options.setError(friendly); - } finally { - setBundleImportBusy(false); - } - }; - - const openTeamBundle = async (input: { - templateId?: string; - name: string; - templateData: unknown; - organizationName?: string | null; - }) => { - const bundle = parseBundlePayload(input.templateData); - options.setError(null); - options.setView("settings"); - options.setSettingsTab("general"); - setSkillDestinationBusyId(null); - setBundleImportError(null); - setBundleStartRequest(null); - setCreateWorkspaceRequest(null); - - if (bundle.type === "skill") { - setBundleImportChoice(null); - setSkillDestinationRequest({ - request: { - intent: "import_current", - source: "team-template", - label: input.name, - }, - bundle, - }); - return; - } - - setSkillDestinationRequest(null); - setBundleImportChoice({ - request: { - intent: "import_current", - source: "team-template", - label: input.name, - }, - bundle, - }); - }; - - const startWorkspaceFromTeamTemplate = async (input: { - name: string; - templateData: unknown; - folder: string | null; - preset?: WorkspacePreset; - }) => { - const bundle = parseBundlePayload(input.templateData); - if (bundle.type !== "workspace-profile") { - throw new Error("Only workspace templates can start a new workspace."); - } - - options.setError(null); - setSkillDestinationRequest(null); - setBundleImportChoice(null); - setBundleImportError(null); - setCreateWorkspaceRequest(null); - setBundleStartRequest(null); - - const imported = await createWorkspaceFromBundle( - bundle, - input.folder, - input.preset ?? defaultPresetFromWorkspaceProfileBundle(bundle), - ); - if (!imported) { - throw new Error(`Failed to create ${input.name} from template.`); - } - }; - - const bundleImportSummary = createMemo(() => { - const choice = bundleImportChoice(); - return choice ? describeBundleImport(choice.bundle) : null; - }); - - const bundleStartItems = createMemo(() => { - const request = bundleStartRequest(); - return request ? describeBundleImport(request.bundle).items : []; - }); - - const createWorkspaceDefaultPreset = createMemo(() => createWorkspaceRequest()?.defaultPreset ?? "starter"); - - const skillDestinationWorkspaces = createMemo(() => { - const activeId = options.workspaceStore.selectedWorkspaceId(); - return options.workspaceStore - .workspaces() - .filter((workspace) => isBundleImportWorkspace(workspace)) - .slice() - .sort((a, b) => { - if (a.id === activeId && b.id !== activeId) return -1; - if (b.id === activeId && a.id !== activeId) return 1; - const aLabel = - a.displayName?.trim() || - a.openworkWorkspaceName?.trim() || - a.name?.trim() || - a.directory?.trim() || - a.path?.trim() || - a.baseUrl?.trim() || - ""; - const bLabel = - b.displayName?.trim() || - b.openworkWorkspaceName?.trim() || - b.name?.trim() || - b.directory?.trim() || - b.path?.trim() || - b.baseUrl?.trim() || - ""; - return aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); - }); - }); - - const bundleWorkerOptions = createMemo(() => { - const selectedWorkspaceId = options.workspaceStore.selectedWorkspaceId().trim(); - const items = options.workspaceStore.workspaces().map((workspace) => { - let disabledReason: string | null = null; - if (!resolveBundleImportTargetForWorkspace(workspace)) { - disabledReason = - workspace.workspaceType === "remote" && workspace.remoteType !== "openwork" - ? "Only OpenWork-connected workers support direct bundle imports." - : "This worker is missing the info OpenWork needs to import the bundle."; - } - - const label = - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - workspace.path?.trim() || - t("app.worker_fallback"); - const badge = - workspace.workspaceType === "remote" - ? isSandboxWorkspace(workspace) - ? t("workspace.sandbox_badge") - : t("workspace.remote_badge") - : t("workspace.local_badge"); - const detail = - workspace.workspaceType === "local" - ? workspace.path?.trim() || t("app.local_worker_detail") - : workspace.directory?.trim() || workspace.baseUrl?.trim() || workspace.openworkHostUrl?.trim() || t("app.remote_worker_detail"); - - return { - id: workspace.id, - label, - detail, - badge, - current: workspace.id === selectedWorkspaceId, - disabledReason, - } satisfies BundleWorkerOption; - }); - - return items.sort((a, b) => { - if (a.current !== b.current) return a.current ? -1 : 1; - return a.label.localeCompare(b.label); - }); - }); - - const openCreateWorkspaceFromChoice = async () => { - const choice = bundleImportChoice(); - if (!choice || bundleImportBusy()) return; - - setBundleImportError(null); - options.setError(null); - - if (isTauriRuntime()) { - options.setView("settings"); - options.setSettingsTab("skills"); - setCreateWorkspaceRequest({ - request: choice.request, - bundle: choice.bundle, - defaultPreset: choice.bundle.type === "workspace-profile" ? defaultPresetFromWorkspaceProfileBundle(choice.bundle) : "starter", - }); - setBundleImportChoice(null); - options.workspaceStore.setCreateWorkspaceOpen(true); - return; - } - - setBundleImportBusy(true); - try { - await createWorkerForBundle(choice.request, choice.bundle); - await importBundlePayload(choice.bundle, resolveActiveBundleImportTarget()); - setBundleImportChoice(null); - options.setError(null); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - setBundleImportError(friendly); - options.setError(friendly); - } finally { - setBundleImportBusy(false); - } - }; - - const importBundleIntoExistingWorkspace = async (workspaceId: string) => { - const choice = bundleImportChoice(); - if (!choice || bundleImportBusy()) return; - - const workspace = options.workspaceStore.workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace) { - setBundleImportError("The selected worker is no longer available."); - return; - } - - const target = resolveBundleImportTargetForWorkspace(workspace); - if (!target) { - setBundleImportError("This worker cannot accept bundle imports yet."); - return; - } - - setBundleImportBusy(true); - setBundleImportError(null); - options.setError(null); - - try { - options.setView("settings"); - options.setSettingsTab(choice.bundle.type === "workspace-profile" ? "general" : "skills"); - const ok = await options.workspaceStore.activateWorkspace(workspace.id); - if (!ok) { - throw new Error(options.error() || `Failed to switch to ${workspace.displayName?.trim() || workspace.name || "the selected worker"}.`); - } - await importBundlePayload(choice.bundle, target); - setBundleImportChoice(null); - options.setError(null); - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - const friendly = addOpencodeCacheHint(message); - setBundleImportError(friendly); - options.setError(friendly); - } finally { - setBundleImportBusy(false); - } - }; - - const openCreateWorkspaceFromSkillDestination = () => { - const request = skillDestinationRequest(); - if (!request) return; - options.setError(null); - setCreateWorkspaceRequest({ - request: request.request, - bundle: request.bundle, - defaultPreset: "minimal", - }); - options.workspaceStore.setCreateWorkspaceOpen(true); - }; - - const openRemoteConnectFromSkillDestination = () => { - options.setError(null); - options.workspaceStore.setCreateRemoteWorkspaceOpen(true); - }; - - const handleCreateWorkspaceConfirm = async (preset: WorkspacePreset, folder: string | null) => { - const request = createWorkspaceRequest(); - const ok = await options.workspaceStore.createWorkspaceFlow(preset, folder); - if (!ok || !request) return; - - const imported = await importBundleIntoActiveWorker( - request.request, - { - localRoot: options.workspaceStore.selectedWorkspaceRoot().trim(), - }, - request.bundle, - ); - setCreateWorkspaceRequest(null); - if (imported) { - if (request.bundle.type === "skill") { - showSkillSuccessToast({ - title: t("app.skill_added"), - description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(options.workspaceStore.selectedWorkspaceDisplay())}.`, - }); - } - setSkillDestinationRequest(null); - } - }; - - const handleCreateSandboxConfirm = async (preset: WorkspacePreset, folder: string | null) => { - const request = createWorkspaceRequest(); - const ok = await options.workspaceStore.createSandboxFlow( - preset, - folder, - request - ? { - onReady: async () => { - const active = options.workspaceStore.selectedWorkspaceDisplay(); - await importBundleIntoActiveWorker( - request.request, - { - workspaceId: - active.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(active.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(active.baseUrl ?? "") || - null, - directoryHint: active.directory?.trim() || active.path?.trim() || null, - }, - request.bundle, - ); - if (request.bundle.type === "skill") { - showSkillSuccessToast({ - title: t("app.skill_added"), - description: `Added '${request.bundle.name.trim() || "Shared skill"}' to ${describeWorkspaceForBundleToasts(active)}.`, - }); - } - }, - } - : undefined, - ); - if (!ok) return; - setCreateWorkspaceRequest(null); - if (request) { - setSkillDestinationRequest(null); - } - }; - - return { - queueBundleLink, - openDebugBundleRequest, - openTeamBundle, - startWorkspaceFromTeamTemplate, - closeBundleImportChoice, - openCreateWorkspaceFromChoice, - importBundleIntoExistingWorkspace, - clearBundleStartRequest: () => { - if (bundleStartBusy()) return; - setBundleStartRequest(null); - }, - startWorkspaceFromBundle, - clearCreateWorkspaceRequest: () => setCreateWorkspaceRequest(null), - clearSkillDestinationRequest: () => { - if (skillDestinationBusyId()) return; - setSkillDestinationRequest(null); - }, - importSkillIntoWorkspace, - openCreateWorkspaceFromSkillDestination, - openRemoteConnectFromSkillDestination, - handleCreateWorkspaceConfirm, - handleCreateSandboxConfirm, - dismissUntrustedBundleWarning, - confirmUntrustedBundleWarning, - bundleImportChoice, - bundleImportSummary, - bundleWorkerOptions, - bundleImportBusy, - bundleImportError, - bundleStartRequest, - bundleStartItems, - bundleStartBusy, - createWorkspaceRequest, - createWorkspaceDefaultPreset, - untrustedBundleWarning, - skillDestinationRequest, - skillDestinationWorkspaces, - skillDestinationBusyId, - }; -} diff --git a/apps/app/src/app/cloud/den-auth-provider.tsx b/apps/app/src/app/cloud/den-auth-provider.tsx deleted file mode 100644 index ed7ee093..00000000 --- a/apps/app/src/app/cloud/den-auth-provider.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { createContext, createMemo, createSignal, onCleanup, onMount, useContext, type Accessor, type ParentProps } from "solid-js"; -import { clearDenSession, createDenClient, DenApiError, ensureDenActiveOrganization, readDenSettings, type DenUser } from "../lib/den"; -import { denSessionUpdatedEvent } from "../lib/den-session-events"; -import { recordDevLog } from "../lib/dev-log"; - -type DenAuthStatus = "checking" | "signed_in" | "signed_out"; - -type DenAuthStore = { - status: Accessor; - user: Accessor; - error: Accessor; - isSignedIn: Accessor; - refresh: () => Promise; -}; - -const DenAuthContext = createContext(); - -function logDenAuth(label: string, payload?: unknown) { - try { - recordDevLog(true, { level: "debug", source: "den-auth", label, payload }); - if (payload === undefined) { - console.log(`[DEN-AUTH] ${label}`); - } else { - console.log(`[DEN-AUTH] ${label}`, payload); - } - } catch { - // ignore - } -} - -export function DenAuthProvider(props: ParentProps) { - const [status, setStatus] = createSignal("checking"); - const [user, setUser] = createSignal(null); - const [error, setError] = createSignal(null); - let refreshToken = 0; - - const refresh = async () => { - const currentRun = ++refreshToken; - const settings = readDenSettings(); - const token = settings.authToken?.trim() ?? ""; - - logDenAuth("refresh-start", { - currentRun, - hasToken: Boolean(token), - activeOrgId: settings.activeOrgId ?? null, - activeOrgSlug: settings.activeOrgSlug ?? null, - baseUrl: settings.baseUrl, - }); - - if (!token) { - setUser(null); - setError(null); - setStatus("signed_out"); - logDenAuth("refresh-signed-out-no-token", { currentRun }); - return; - } - - setStatus("checking"); - logDenAuth("refresh-status-checking", { currentRun }); - - try { - const nextUser = await createDenClient({ - baseUrl: settings.baseUrl, - apiBaseUrl: settings.apiBaseUrl, - token, - }).getSession(); - - if (currentRun !== refreshToken) { - logDenAuth("refresh-stale-after-session", { currentRun, refreshToken }); - return; - } - - await ensureDenActiveOrganization({ - forceServerSync: - !settings.activeOrgId?.trim() || - !settings.activeOrgSlug?.trim(), - }).catch(() => null); - - if (currentRun !== refreshToken) { - logDenAuth("refresh-stale-after-org-sync", { currentRun, refreshToken }); - return; - } - - setUser(nextUser); - setError(null); - setStatus("signed_in"); - logDenAuth("refresh-signed-in", { - currentRun, - userId: nextUser.id, - activeOrgId: readDenSettings().activeOrgId ?? null, - activeOrgSlug: readDenSettings().activeOrgSlug ?? null, - }); - } catch (nextError) { - if (currentRun !== refreshToken) { - logDenAuth("refresh-stale-after-error", { currentRun, refreshToken }); - return; - } - - if (nextError instanceof DenApiError && nextError.status === 401) { - clearDenSession(); - } - - setUser(null); - setError(nextError instanceof Error ? nextError.message : "Failed to restore OpenWork Cloud session."); - setStatus("signed_out"); - logDenAuth("refresh-error", { - currentRun, - error: nextError instanceof Error ? nextError.message : String(nextError), - }); - } - }; - - onMount(() => { - void refresh(); - - if (typeof window === "undefined") { - return; - } - - const handleSessionUpdated = () => { - logDenAuth("session-updated-event"); - void refresh(); - }; - - window.addEventListener(denSessionUpdatedEvent, handleSessionUpdated); - onCleanup(() => { - window.removeEventListener(denSessionUpdatedEvent, handleSessionUpdated); - }); - }); - - const store: DenAuthStore = { - status, - user, - error, - isSignedIn: createMemo(() => status() === "signed_in"), - refresh, - }; - - return ( - - {props.children} - - ); -} - -export function useDenAuth() { - const context = useContext(DenAuthContext); - if (!context) { - throw new Error("useDenAuth must be used within a DenAuthProvider"); - } - return context; -} diff --git a/apps/app/src/app/cloud/den-signin-surface.tsx b/apps/app/src/app/cloud/den-signin-surface.tsx deleted file mode 100644 index e3f995fb..00000000 --- a/apps/app/src/app/cloud/den-signin-surface.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { ArrowUpRight, Cloud } from "lucide-solid"; -import { Show } from "solid-js"; -import { currentLocale, t } from "../../i18n"; -import { DEFAULT_DEN_BASE_URL } from "../lib/den"; -import Button from "../components/button"; -import TextInput from "../components/text-input"; - -type DenSignInSurfaceProps = { - variant?: "panel" | "fullscreen"; - developerMode: boolean; - baseUrl: string; - baseUrlDraft: string; - baseUrlError: string | null; - statusMessage: string | null; - authError: string | null; - authBusy: boolean; - baseUrlBusy: boolean; - sessionBusy: boolean; - manualAuthOpen: boolean; - manualAuthInput: string; - onBaseUrlDraftInput: (value: string) => void; - onResetBaseUrl: () => void; - onApplyBaseUrl: () => void; - onOpenControlPlane: () => void; - onOpenBrowserAuth: (mode: "sign-in" | "sign-up") => void; - onToggleManualAuth: () => void; - onManualAuthInput: (value: string) => void; - onSubmitManualAuth: () => void; -}; - -export default function DenSignInSurface(props: DenSignInSurfaceProps) { - const tr = (key: string) => t(key, currentLocale()); - const variant = () => props.variant ?? "panel"; - const settingsPanelClass = "ow-soft-card rounded-[28px] p-5 md:p-6"; - const settingsPanelSoftClass = "ow-soft-card-quiet rounded-2xl p-4"; - const headerBadgeClass = - "inline-flex min-h-8 items-center gap-2 rounded-xl border border-dls-border bg-dls-hover px-3 text-[13px] font-medium text-dls-text shadow-sm"; - const softNoticeClass = - "rounded-xl border border-dls-border bg-dls-hover px-3 py-2 text-xs text-dls-secondary"; - - const content = ( -
-
-
-
- - {tr("den.cloud_section_title")} -
-
-
- {tr("den.signin_title")} -
-
-
-
- - -
- - props.onBaseUrlDraftInput(event.currentTarget.value) - } - placeholder={DEFAULT_DEN_BASE_URL} - hint={tr("den.cloud_control_plane_url_hint")} - disabled={props.authBusy || props.baseUrlBusy || props.sessionBusy} - /> -
- - - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
- - - {(value) =>
{value()}
} -
- -
-
- {tr("den.auto_reconnect_hint")} -
-
- -
- - - -
- - -
- - props.onManualAuthInput(event.currentTarget.value) - } - placeholder={tr("den.signin_link_placeholder")} - disabled={props.authBusy || props.sessionBusy} - hint={tr("den.signin_link_hint")} - /> -
- -
- {tr("den.signin_code_note")} -
-
-
-
- - - {(value) => ( -
- {value()} -
- )} -
-
- ); - - if (variant() === "fullscreen") { - return ( -
-
-
{content}
-
-
- ); - } - - return content; -} diff --git a/apps/app/src/app/cloud/desktop-config-provider.tsx b/apps/app/src/app/cloud/desktop-config-provider.tsx deleted file mode 100644 index 935f6a30..00000000 --- a/apps/app/src/app/cloud/desktop-config-provider.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { createContext, createEffect, createSignal, onCleanup, onMount, useContext, type Accessor, type ParentProps } from "solid-js"; -import { createDenClient, DenApiError, ensureDenActiveOrganization, type DenDesktopConfig, normalizeDenDesktopConfig, readDenSettings } from "../lib/den"; -import { denSessionUpdatedEvent, denSettingsChangedEvent } from "../lib/den-session-events"; -import { useDenAuth } from "./den-auth-provider"; -import { checkDesktopAppRestriction, type DesktopAppRestrictionChecker } from "./desktop-app-restrictions"; - -type DesktopConfigStore = { - config: Accessor; - loading: Accessor; - refresh: () => Promise; - checkRestriction: DesktopAppRestrictionChecker; -}; - -const DesktopConfigContext = createContext(); - -const DEFAULT_DESKTOP_CONFIG: DenDesktopConfig = {}; -const DESKTOP_CONFIG_REFRESH_MS = 60 * 60 * 1000; -const DESKTOP_CONFIG_CACHE_PREFIX = "openwork.den.desktopConfig:"; - -function getDesktopConfigCacheKey() { - const settings = readDenSettings(); - const baseUrl = settings.baseUrl.trim(); - const activeOrgId = settings.activeOrgId?.trim() ?? ""; - if (!baseUrl) return ""; - return `${DESKTOP_CONFIG_CACHE_PREFIX}${baseUrl}::${activeOrgId}`; -} - -function readCachedDesktopConfig(key: string): DenDesktopConfig | null { - if (typeof window === "undefined" || !key) { - return null; - } - - try { - const raw = window.localStorage.getItem(key); - if (!raw) { - return null; - } - return normalizeDenDesktopConfig(JSON.parse(raw)); - } catch { - return null; - } -} - -function writeCachedDesktopConfig(key: string, config: DenDesktopConfig) { - if (typeof window === "undefined" || !key) { - return; - } - - try { - window.localStorage.setItem(key, JSON.stringify(normalizeDenDesktopConfig(config))); - } catch { - // ignore - } -} - -export function DesktopConfigProvider(props: ParentProps) { - const denAuth = useDenAuth(); - const [config, setConfig] = createSignal(DEFAULT_DESKTOP_CONFIG); - const [loading, setLoading] = createSignal(false); - const [settingsVersion, setSettingsVersion] = createSignal(0); - let refreshRunId = 0; - - const refresh = async () => { - const currentRun = ++refreshRunId; - const settings = readDenSettings(); - const token = settings.authToken?.trim() ?? ""; - const cacheKey = getDesktopConfigCacheKey(); - - if (!denAuth.isSignedIn() || !token || !settings.activeOrgId?.trim()) { - setConfig(DEFAULT_DESKTOP_CONFIG); - setLoading(false); - return; - } - - const cached = readCachedDesktopConfig(cacheKey); - if (!cached) { - setLoading(true); - } - - try { - const nextConfig = await createDenClient({ - baseUrl: settings.baseUrl, - apiBaseUrl: settings.apiBaseUrl, - token, - }).getDesktopConfig(); - - if (currentRun !== refreshRunId) { - return; - } - - writeCachedDesktopConfig(cacheKey, nextConfig); - setConfig(nextConfig); - } catch (error) { - if (currentRun !== refreshRunId) { - return; - } - - if ( - error instanceof DenApiError && - error.status === 404 && - error.code === "organization_not_found" - ) { - await ensureDenActiveOrganization({ forceServerSync: true }).catch(() => null); - } - - setConfig(cached ?? DEFAULT_DESKTOP_CONFIG); - } finally { - if (currentRun === refreshRunId) { - setLoading(false); - } - } - }; - - createEffect(() => { - settingsVersion(); - - if (!denAuth.isSignedIn()) { - setConfig(DEFAULT_DESKTOP_CONFIG); - setLoading(false); - return; - } - - const cacheKey = getDesktopConfigCacheKey(); - const cached = readCachedDesktopConfig(cacheKey); - setConfig(cached ?? DEFAULT_DESKTOP_CONFIG); - setLoading(!cached); - void refresh(); - }); - - onMount(() => { - if (typeof window === "undefined") { - return; - } - - const handleSettingsChanged = () => { - setSettingsVersion((value) => value + 1); - }; - - window.addEventListener(denSessionUpdatedEvent, handleSettingsChanged); - window.addEventListener(denSettingsChangedEvent, handleSettingsChanged); - - const interval = window.setInterval(() => { - if (!denAuth.isSignedIn()) { - return; - } - void refresh(); - }, DESKTOP_CONFIG_REFRESH_MS); - - onCleanup(() => { - window.removeEventListener(denSessionUpdatedEvent, handleSettingsChanged); - window.removeEventListener(denSettingsChangedEvent, handleSettingsChanged); - window.clearInterval(interval); - }); - }); - - const store: DesktopConfigStore = { - config, - loading, - refresh, - checkRestriction(input) { - return checkDesktopAppRestriction({ - config: config(), - restriction: input.restriction, - }); - }, - }; - - return ( - - {props.children} - - ); -} - -export function useDesktopConfig() { - const context = useContext(DesktopConfigContext); - if (!context) { - throw new Error("useDesktopConfig must be used within a DesktopConfigProvider"); - } - return context; -} diff --git a/apps/app/src/app/cloud/forced-signin-page.tsx b/apps/app/src/app/cloud/forced-signin-page.tsx deleted file mode 100644 index 2c936d4f..00000000 --- a/apps/app/src/app/cloud/forced-signin-page.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { createSignal, onCleanup, onMount } from "solid-js"; -import DenSignInSurface from "./den-signin-surface"; -import { useDenAuth } from "./den-auth-provider"; -import { useDesktopConfig } from "./desktop-config-provider"; -import { usePlatform } from "../context/platform"; -import { currentLocale, t } from "../../i18n"; -import { - buildDenAuthUrl, - clearDenSession, - createDenClient, - DEFAULT_DEN_BASE_URL, - normalizeDenBaseUrl, - readDenBootstrapConfig, - readDenSettings, - resolveDenBaseUrls, - setDenBootstrapConfig, - writeDenSettings, -} from "../lib/den"; -import { denSessionUpdatedEvent, dispatchDenSessionUpdated, type DenSessionUpdatedDetail } from "../lib/den-session-events"; - -type ForcedSigninPageProps = { - developerMode: boolean; -}; - -export default function ForcedSigninPage(props: ForcedSigninPageProps) { - const platform = usePlatform(); - const denAuth = useDenAuth(); - const desktopConfig = useDesktopConfig(); - const initial = readDenSettings(); - const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL; - - const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); - const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl); - const [baseUrlError, setBaseUrlError] = createSignal(null); - const [authBusy, setAuthBusy] = createSignal(false); - const [baseUrlBusy, setBaseUrlBusy] = createSignal(false); - const [manualAuthOpen, setManualAuthOpen] = createSignal(false); - const [manualAuthInput, setManualAuthInput] = createSignal(""); - const [authError, setAuthError] = createSignal(null); - const [statusMessage, setStatusMessage] = createSignal(null); - - const openControlPlane = () => { - platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); - }; - - const openBrowserAuth = (mode: "sign-in" | "sign-up") => { - platform.openLink(buildDenAuthUrl(baseUrl(), mode)); - setStatusMessage( - mode === "sign-up" - ? t("den.status_browser_signup", currentLocale()) - : t("den.status_browser_signin", currentLocale()), - ); - setAuthError(null); - }; - - const parseManualAuthInput = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return null; - - try { - const url = new URL(trimmed); - const protocol = url.protocol.toLowerCase(); - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if ( - (protocol === "openwork:" || protocol === "openwork-dev:") && - (routeHost === "den-auth" || routePath === "den-auth" || routeTail === "den-auth") - ) { - const grant = url.searchParams.get("grant")?.trim() ?? ""; - const nextBaseUrl = - normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? undefined; - return grant ? { grant, baseUrl: nextBaseUrl } : null; - } - } catch { - // treat non-URL input as a raw handoff grant - } - - return trimmed.length >= 12 ? { grant: trimmed } : null; - }; - - const submitManualAuth = async () => { - const parsed = parseManualAuthInput(manualAuthInput()); - if (!parsed || authBusy()) { - if (!parsed) { - setAuthError(t("den.error_paste_valid_code", currentLocale())); - } - return; - } - - const nextBaseUrl = parsed.baseUrl ?? baseUrl(); - - setAuthBusy(true); - setAuthError(null); - setStatusMessage(t("den.signing_in", currentLocale())); - - try { - const result = await createDenClient({ baseUrl: nextBaseUrl }).exchangeDesktopHandoff(parsed.grant); - if (!result.token) { - throw new Error(t("den.error_no_token", currentLocale())); - } - - if (props.developerMode) { - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - } - - writeDenSettings({ - baseUrl: nextBaseUrl, - authToken: result.token, - activeOrgId: null, - activeOrgSlug: null, - activeOrgName: null, - }); - - setManualAuthInput(""); - setManualAuthOpen(false); - dispatchDenSessionUpdated({ - status: "success", - baseUrl: nextBaseUrl, - token: result.token, - user: result.user, - email: result.user?.email ?? null, - }); - } catch (error) { - dispatchDenSessionUpdated({ - status: "error", - message: error instanceof Error ? error.message : t("den.error_signin_failed", currentLocale()), - }); - } finally { - setAuthBusy(false); - } - }; - - const applyBaseUrl = async () => { - const normalized = normalizeDenBaseUrl(baseUrlDraft()); - if (!normalized) { - setBaseUrlError(t("den.error_base_url", currentLocale())); - return; - } - - const resolved = resolveDenBaseUrls(normalized); - setBaseUrlBusy(true); - - try { - await setDenBootstrapConfig({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - requireSignin: readDenBootstrapConfig().requireSignin, - }); - setBaseUrlError(null); - setBaseUrl(resolved.baseUrl); - setBaseUrlDraft(resolved.baseUrl); - clearDenSession({ includeBaseUrls: !props.developerMode }); - writeDenSettings({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - authToken: null, - activeOrgId: null, - activeOrgSlug: null, - activeOrgName: null, - }, { persistBootstrap: false }); - setAuthError(null); - setStatusMessage(t("den.status_base_url_updated", currentLocale())); - void desktopConfig.refresh(); - void denAuth.refresh(); - } catch (error) { - setBaseUrlError( - error instanceof Error ? error.message : t("den.error_base_url", currentLocale()), - ); - } finally { - setBaseUrlBusy(false); - } - }; - - onMount(() => { - if (typeof window === "undefined") { - return; - } - - const handler = (event: Event) => { - const customEvent = event as CustomEvent; - const nextSettings = readDenSettings(); - const nextBaseUrl = - customEvent.detail?.baseUrl?.trim() || - nextSettings.baseUrl || - DEFAULT_DEN_BASE_URL; - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - if (customEvent.detail?.status === "success") { - setAuthError(null); - setStatusMessage( - customEvent.detail.email?.trim() - ? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() }) - : t("den.status_cloud_signin_done", currentLocale()), - ); - } else if (customEvent.detail?.status === "error") { - setAuthError( - customEvent.detail.message?.trim() || - t("den.error_signin_failed", currentLocale()), - ); - } - }; - - window.addEventListener(denSessionUpdatedEvent, handler as EventListener); - onCleanup(() => { - window.removeEventListener(denSessionUpdatedEvent, handler as EventListener); - }); - }); - - return ( - setBaseUrlDraft(baseUrl())} - onApplyBaseUrl={() => { - void applyBaseUrl(); - }} - onOpenControlPlane={openControlPlane} - onOpenBrowserAuth={openBrowserAuth} - onToggleManualAuth={() => { - setManualAuthOpen((value) => !value); - setAuthError(null); - }} - onManualAuthInput={setManualAuthInput} - onSubmitManualAuth={() => { - void submitManualAuth(); - }} - /> - ); -} diff --git a/apps/app/src/app/components/add-mcp-modal.tsx b/apps/app/src/app/components/add-mcp-modal.tsx deleted file mode 100644 index 9301c0e8..00000000 --- a/apps/app/src/app/components/add-mcp-modal.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { Show, createSignal } from "solid-js"; -import { Loader2, Plus, X } from "lucide-solid"; -import Button from "./button"; -import TextInput from "./text-input"; -import type { McpDirectoryInfo } from "../constants"; -import { t, type Language } from "../../i18n"; - -export type AddMcpModalProps = { - open: boolean; - onClose: () => void; - onAdd: (entry: McpDirectoryInfo) => void; - busy: boolean; - isRemoteWorkspace: boolean; - language: Language; -}; - -export default function AddMcpModal(props: AddMcpModalProps) { - const tr = (key: string) => t(key, props.language); - - const [name, setName] = createSignal(""); - const [serverType, setServerType] = createSignal<"remote" | "local">("remote"); - const [url, setUrl] = createSignal(""); - const [command, setCommand] = createSignal(""); - const [oauthRequired, setOauthRequired] = createSignal(false); - const [error, setError] = createSignal(null); - const [submitting, setSubmitting] = createSignal(false); - - const reset = () => { - setName(""); - setServerType("remote"); - setUrl(""); - setCommand(""); - setOauthRequired(false); - setError(null); - }; - - const handleClose = () => { - if (submitting()) return; - reset(); - props.onClose(); - }; - - const handleSubmit = async () => { - if (submitting()) return; - setError(null); - - const trimmedName = name().trim(); - if (!trimmedName) { - setError(tr("mcp.name_required")); - return; - } - - setSubmitting(true); - - if (serverType() === "remote") { - const trimmedUrl = url().trim(); - if (!trimmedUrl) { - setError(tr("mcp.url_or_command_required")); - setSubmitting(false); - return; - } - - try { - await Promise.resolve(props.onAdd({ - name: trimmedName, - description: "", - type: "remote", - url: trimmedUrl, - oauth: oauthRequired(), - })); - } finally { - setSubmitting(false); - } - } else { - const trimmedCommand = command().trim(); - if (!trimmedCommand) { - setError(tr("mcp.url_or_command_required")); - setSubmitting(false); - return; - } - - try { - await Promise.resolve(props.onAdd({ - name: trimmedName, - description: "", - type: "local", - command: trimmedCommand.split(/\s+/), - oauth: false, - })); - } finally { - setSubmitting(false); - } - } - - handleClose(); - }; - - return ( - -
-
- -
event.stopPropagation()} - > - {/* Header */} -
-
-

- {tr("mcp.add_modal_title")} -

-

{tr("mcp.add_modal_subtitle")}

-
- -
- - {/* Content */} -
- setName(e.currentTarget.value)} - autofocus - /> - -
-
{tr("mcp.server_type")}
-
- - -
- -
{tr("mcp.remote_workspace_url_hint")}
-
-
- - -
- setUrl(e.currentTarget.value)} - /> -
-
{tr("mcp.sign_in_section_label")}
- -
-
-
- - - setCommand(e.currentTarget.value)} - /> - - - -
- {error()} -
-
-
- - {/* Footer */} -
- - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/button.tsx b/apps/app/src/app/components/button.tsx deleted file mode 100644 index c5ff94a1..00000000 --- a/apps/app/src/app/components/button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { splitProps } from "solid-js"; -import type { JSX } from "solid-js"; - -type ButtonProps = JSX.ButtonHTMLAttributes & { - variant?: "primary" | "secondary" | "ghost" | "outline" | "danger"; -}; - -export default function Button(props: ButtonProps) { - const [local, rest] = splitProps(props, ["variant", "class", "disabled", "title", "type"]); - const variant = () => local.variant ?? "primary"; - - const base = - "inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-150 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] disabled:opacity-50 disabled:cursor-not-allowed"; - - const variants: Record, string> = { - primary: "bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)] border border-transparent shadow-[0_1px_2px_rgba(17,24,39,0.12)]", - secondary: "bg-gray-12 text-gray-1 hover:bg-gray-11 border border-transparent font-semibold", - ghost: "bg-transparent text-dls-secondary hover:text-dls-text hover:bg-dls-hover", - outline: "border border-dls-border text-dls-text hover:bg-dls-hover bg-transparent", - danger: "bg-red-3 text-red-11 hover:bg-red-4 border border-red-6", - }; - - return ( - - -
- - - -
- ); -} diff --git a/apps/app/src/app/components/control-chrome-setup-modal.tsx b/apps/app/src/app/components/control-chrome-setup-modal.tsx deleted file mode 100644 index d8674014..00000000 --- a/apps/app/src/app/components/control-chrome-setup-modal.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { Show, createEffect, createSignal } from "solid-js"; -import { Check, ExternalLink, Loader2, MonitorSmartphone, Settings2, X } from "lucide-solid"; -import Button from "./button"; -import { t, type Language } from "../../i18n"; - -export type ControlChromeSetupModalProps = { - open: boolean; - busy: boolean; - language: Language; - mode: "connect" | "edit"; - initialUseExistingProfile: boolean; - onClose: () => void; - onSave: (useExistingProfile: boolean) => void; -}; - -export default function ControlChromeSetupModal(props: ControlChromeSetupModalProps) { - const tr = (key: string) => t(key, props.language); - const [useExistingProfile, setUseExistingProfile] = createSignal(props.initialUseExistingProfile); - - createEffect(() => { - if (!props.open) return; - setUseExistingProfile(props.initialUseExistingProfile); - }); - - return ( - -
-
- -
-
-
-
-
- - Chrome DevTools MCP -
-
-

- {tr("mcp.control_chrome_setup_title")} -

-

- {tr("mcp.control_chrome_setup_subtitle")} -

-
-
- -
-
- -
-
-
-
- -
-
-

- {tr("mcp.control_chrome_browser_title")} -

-

- {tr("mcp.control_chrome_browser_hint")} -

-
    -
  1. 1. {tr("mcp.control_chrome_browser_step_one")}
  2. -
  3. 2. {tr("mcp.control_chrome_browser_step_two")}
  4. -
  5. 3. {tr("mcp.control_chrome_browser_step_three")}
  6. -
- - {tr("mcp.control_chrome_docs")} - - -
-
-
- -
-
-
- -
-
-

- {tr("mcp.control_chrome_profile_title")} -

-

- {tr("mcp.control_chrome_profile_hint")} -

- - - -
- {useExistingProfile() - ? tr("mcp.control_chrome_toggle_on") - : tr("mcp.control_chrome_toggle_off")} -
-
-
-
-
- -
- - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/den-settings-panel.tsx b/apps/app/src/app/components/den-settings-panel.tsx deleted file mode 100644 index 8658ec53..00000000 --- a/apps/app/src/app/components/den-settings-panel.tsx +++ /dev/null @@ -1,1970 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; -import { ArrowUpRight, Boxes, Brain, Cloud, KeyRound, LogOut, Package, RefreshCcw, Server, Users } from "lucide-solid"; - -import Button from "./button"; -import TextInput from "./text-input"; -import DenSignInSurface from "../cloud/den-signin-surface"; -import { currentLocale, t } from "../../i18n"; -import { - buildDenAuthUrl, - clearDenSession, - DEFAULT_DEN_BASE_URL, - DenApiError, - type DenOrgSkillHub, - type DenOrgLlmProvider, - type DenTemplate, - createDenClient, - normalizeDenBaseUrl, - readDenBootstrapConfig, - readDenSettings, - resolveDenBaseUrls, - setDenBootstrapConfig, - writeDenSettings, -} from "../lib/den"; -import type { DenOrgSkillCard } from "../types"; -import type { CloudImportedProvider, CloudImportedSkill, CloudImportedSkillHub } from "../cloud/import-state"; -import { - denSettingsChangedEvent, - denSessionUpdatedEvent, - dispatchDenSessionUpdated, - type DenSessionUpdatedDetail, -} from "../lib/den-session-events"; -import { - clearDenTemplateCache, - loadDenTemplateCache, - readDenTemplateCacheSnapshot, -} from "../lib/den-template-cache"; -import { usePlatform } from "../context/platform"; -import { useExtensions } from "../extensions/provider"; - -type DenSettingsPanelProps = { - developerMode: boolean; - connectRemoteWorkspace: (input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - }) => Promise; - openTeamBundle: (input: { - templateId: string; - name: string; - templateData: unknown; - organizationName?: string | null; - }) => void | Promise; - cloudOrgProviders: DenOrgLlmProvider[]; - importedCloudProviders: Record; - refreshCloudOrgProviders: (options?: { force?: boolean }) => Promise; - connectCloudProvider: (cloudProviderId: string) => Promise; - removeCloudProvider: (cloudProviderId: string) => Promise; - runCloudProviderSync: (reason: "sign_in" | "app_launch" | "interval" | "settings_cloud_opened") => Promise; -}; - -type CloudSkillHubRow = { - key: string; - hubId: string; - name: string; - hub: DenOrgSkillHub | null; - imported: CloudImportedSkillHub | null; - status: "available" | "imported" | "out_of_sync" | "removed_from_cloud"; - liveSkillCount: number; - importedSkillCount: number; -}; - -type CloudProviderRow = { - key: string; - cloudProviderId: string; - provider: DenOrgLlmProvider | null; - imported: CloudImportedProvider | null; - status: "available" | "imported" | "out_of_sync" | "removed_from_cloud"; - name: string; -}; - -type CloudSkillRow = { - key: string; - cloudSkillId: string; - skill: DenOrgSkillCard | null; - imported: CloudImportedSkill | null; - status: "available" | "installed" | "out_of_sync" | "removed_from_cloud"; - title: string; - installedName: string | null; -}; - -const sortStrings = (values: string[]) => [...values].sort(); - -const sameStringList = (a: string[], b: string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); - -function statusBadgeClass(kind: "ready" | "warning" | "neutral" | "error") { - switch (kind) { - case "ready": - return "border-green-7/30 bg-green-3/20 text-green-11"; - case "warning": - return "border-amber-7/30 bg-amber-3/20 text-amber-11"; - case "error": - return "border-red-7/30 bg-red-3/20 text-red-11"; - default: - return "border-gray-6/60 bg-gray-3/20 text-gray-11"; - } -} - -function workerStatusMeta(status: string, tr: (key: string) => string) { - const normalized = status.trim().toLowerCase(); - switch (normalized) { - case "healthy": - return { label: tr("dashboard.worker_status_ready"), tone: "ready" as const, canOpen: true }; - case "provisioning": - return { label: tr("dashboard.worker_status_starting"), tone: "warning" as const, canOpen: false }; - case "failed": - return { label: tr("dashboard.worker_status_attention"), tone: "error" as const, canOpen: false }; - case "stopped": - return { label: tr("dashboard.worker_status_stopped"), tone: "neutral" as const, canOpen: false }; - default: - return { - label: normalized - ? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}` - : tr("dashboard.worker_status_unknown"), - tone: "neutral" as const, - canOpen: normalized === "ready", - }; - } -} - -export default function DenSettingsPanel(props: DenSettingsPanelProps) { - const platform = usePlatform(); - const extensions = useExtensions(); - const tr = (key: string) => t(key, currentLocale()); - const initial = readDenSettings(); - const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL; - - const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); - const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl); - const [baseUrlError, setBaseUrlError] = createSignal(null); - const [baseUrlBusy, setBaseUrlBusy] = createSignal(false); - const [authToken, setAuthToken] = createSignal(initial.authToken?.trim() || ""); - const [activeOrgId, setActiveOrgId] = createSignal(initial.activeOrgId?.trim() || ""); - const [authBusy, setAuthBusy] = createSignal(false); - const [manualAuthOpen, setManualAuthOpen] = createSignal(false); - const [manualAuthInput, setManualAuthInput] = createSignal(""); - const [sessionBusy, setSessionBusy] = createSignal(false); - const [orgsBusy, setOrgsBusy] = createSignal(false); - const [workersBusy, setWorkersBusy] = createSignal(false); - const [openingWorkerId, setOpeningWorkerId] = createSignal(null); - const [openingTemplateId, setOpeningTemplateId] = createSignal(null); - const [user, setUser] = createSignal<{ - id: string; - email: string; - name: string | null; - } | null>(null); - const [orgs, setOrgs] = createSignal< - Array<{ id: string; name: string; slug: string; role: "owner" | "admin" | "member" }> - >([]); - const [workers, setWorkers] = createSignal< - Array<{ - workerId: string; - workerName: string; - status: string; - instanceUrl: string | null; - provider: string | null; - isMine: boolean; - createdAt: string | null; - }> - >([]); - const [statusMessage, setStatusMessage] = createSignal(null); - const [authError, setAuthError] = createSignal(null); - const [orgsError, setOrgsError] = createSignal(null); - const [workersError, setWorkersError] = createSignal(null); - const [templateActionError, setTemplateActionError] = createSignal(null); - const [skillHubsBusy, setSkillHubsBusy] = createSignal(false); - const [skillHubActionId, setSkillHubActionId] = createSignal(null); - const [skillHubActionKind, setSkillHubActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [skillHubActionError, setSkillHubActionError] = createSignal(null); - const [skillsBusy, setSkillsBusy] = createSignal(false); - const [skillActionId, setSkillActionId] = createSignal(null); - const [skillActionKind, setSkillActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [skillActionError, setSkillActionError] = createSignal(null); - const [providersBusy, setProvidersBusy] = createSignal(false); - const [providerActionId, setProviderActionId] = createSignal(null); - const [providerActionKind, setProviderActionKind] = createSignal<"import" | "remove" | "sync" | null>(null); - const [providerActionError, setProviderActionError] = createSignal(null); - - const activeOrg = createMemo(() => orgs().find((org) => org.id === activeOrgId()) ?? null); - const client = createMemo(() => - createDenClient({ - baseUrl: baseUrl(), - apiBaseUrl: readDenSettings().apiBaseUrl, - token: authToken(), - }), - ); - const isSignedIn = createMemo(() => Boolean(user() && authToken().trim())); - const activeOrgName = createMemo(() => activeOrg()?.name || tr("den.no_org_selected")); - const templateCacheSnapshot = createMemo(() => - readDenTemplateCacheSnapshot({ - baseUrl: baseUrl(), - token: authToken(), - orgSlug: activeOrg()?.slug ?? null, - }), - ); - const templatesBusy = createMemo(() => templateCacheSnapshot().busy); - const templates = createMemo(() => templateCacheSnapshot().templates); - const templatesError = createMemo( - () => templateActionError() ?? templateCacheSnapshot().error, - ); - const skillHubImports = createMemo(() => extensions.importedCloudSkillHubs()); - const skillHubRows = createMemo(() => { - const liveHubs = extensions.cloudOrgSkillHubs(); - const imported = skillHubImports(); - const rows: CloudSkillHubRow[] = liveHubs.map((hub) => { - const importedHub = imported[hub.id] ?? null; - const currentSkillIds = sortStrings(hub.skills.map((skill) => skill.id)); - const importedSkillIds = sortStrings(importedHub?.skillIds ?? []); - const status = !importedHub - ? "available" - : sameStringList(currentSkillIds, importedSkillIds) - ? "imported" - : "out_of_sync"; - return { - key: `live:${hub.id}`, - hubId: hub.id, - name: hub.name, - hub, - imported: importedHub, - status, - liveSkillCount: hub.skills.length, - importedSkillCount: importedHub?.skillNames.length ?? 0, - }; - }); - - for (const importedHub of Object.values(imported)) { - if (liveHubs.some((hub) => hub.id === importedHub.hubId)) continue; - rows.push({ - key: `imported:${importedHub.hubId}`, - hubId: importedHub.hubId, - name: importedHub.name, - hub: null, - imported: importedHub, - status: "removed_from_cloud", - liveSkillCount: 0, - importedSkillCount: importedHub.skillNames.length, - }); - } - - return rows; - }); - const installedSkillNames = createMemo(() => new Set(extensions.skills().map((skill) => skill.name))); - const skillRows = createMemo(() => { - const liveSkills = extensions.cloudOrgSkills(); - const imported = extensions.importedCloudSkills(); - const installedNames = installedSkillNames(); - const rows: CloudSkillRow[] = liveSkills.map((skill) => { - const importedSkill = imported[skill.id] ?? null; - const remoteUpdatedAt = skill.updatedAt ? Date.parse(skill.updatedAt) : Number.NaN; - const importedUpdatedAt = importedSkill?.updatedAt ? Date.parse(importedSkill.updatedAt) : Number.NaN; - const installedName = importedSkill?.installedName?.trim() || null; - const installedLocally = installedName ? installedNames.has(installedName) : false; - const status = !importedSkill - ? "available" - : !installedLocally - ? "out_of_sync" - : Number.isFinite(remoteUpdatedAt) && (!Number.isFinite(importedUpdatedAt) || remoteUpdatedAt > importedUpdatedAt) - ? "out_of_sync" - : "installed"; - - return { - key: `live:${skill.id}`, - cloudSkillId: skill.id, - skill, - imported: importedSkill, - status, - title: skill.title, - installedName, - }; - }); - - for (const importedSkill of Object.values(imported)) { - if (liveSkills.some((skill) => skill.id === importedSkill.cloudSkillId)) continue; - rows.push({ - key: `imported:${importedSkill.cloudSkillId}`, - cloudSkillId: importedSkill.cloudSkillId, - skill: null, - imported: importedSkill, - status: "removed_from_cloud", - title: importedSkill.title, - installedName: importedSkill.installedName, - }); - } - - return rows.sort((a, b) => a.title.localeCompare(b.title)); - }); - const providerRows = createMemo(() => { - const imported = props.importedCloudProviders; - const rows: CloudProviderRow[] = props.cloudOrgProviders.map((provider) => { - const importedProvider = imported[provider.id] ?? null; - const status = !importedProvider - ? "available" - : importedProvider.sourceProviderId !== provider.providerId || - (importedProvider.source ?? null) !== provider.source || - (importedProvider.updatedAt ?? null) !== (provider.updatedAt ?? null) || - !sameStringList(importedProvider.modelIds, sortStrings(provider.models.map((model) => model.id))) - ? "out_of_sync" - : "imported"; - return { - key: `live:${provider.id}`, - cloudProviderId: provider.id, - provider, - imported: importedProvider, - status, - name: provider.name, - }; - }); - - for (const importedProvider of Object.values(imported)) { - if (props.cloudOrgProviders.some((provider) => provider.id === importedProvider.cloudProviderId)) continue; - rows.push({ - key: `imported:${importedProvider.cloudProviderId}`, - cloudProviderId: importedProvider.cloudProviderId, - provider: null, - imported: importedProvider, - status: "removed_from_cloud", - name: importedProvider.name, - }); - } - - return rows; - }); - - const summaryTone = createMemo(() => { - if (authError() || workersError() || orgsError() || templatesError() || skillActionError() || providerActionError() || skillHubActionError()) return "error" as const; - if (sessionBusy() || orgsBusy() || workersBusy() || templatesBusy() || skillsBusy() || providersBusy() || skillHubsBusy()) return "warning" as const; - if (isSignedIn()) return "ready" as const; - return "neutral" as const; - }); - - const summaryLabel = createMemo(() => { - if (authError()) return tr("den.needs_attention"); - if (sessionBusy()) return tr("den.checking_session"); - if (isSignedIn()) return t("dashboard.connected", currentLocale()); - return tr("den.signed_out"); - }); - - createEffect(() => { - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: activeOrgId() || null, - activeOrgSlug: activeOrg()?.slug ?? null, - activeOrgName: activeOrg()?.name ?? null, - }); - }); - - const openControlPlane = () => { - platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); - }; - - const openBrowserAuth = (mode: "sign-in" | "sign-up") => { - platform.openLink(buildDenAuthUrl(baseUrl(), mode)); - setStatusMessage( - mode === "sign-up" - ? tr("den.status_browser_signup") - : tr("den.status_browser_signin"), - ); - setAuthError(null); - }; - - const parseManualAuthInput = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return null; - - try { - const url = new URL(trimmed); - const protocol = url.protocol.toLowerCase(); - const routeHost = url.hostname.toLowerCase(); - const routePath = url.pathname.replace(/^\/+/, "").toLowerCase(); - const routeSegments = routePath.split("/").filter(Boolean); - const routeTail = routeSegments[routeSegments.length - 1] ?? ""; - if ( - (protocol === "openwork:" || protocol === "openwork-dev:") && - (routeHost === "den-auth" || routePath === "den-auth" || routeTail === "den-auth") - ) { - const grant = url.searchParams.get("grant")?.trim() ?? ""; - const nextBaseUrl = - normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? "") ?? undefined; - return grant ? { grant, baseUrl: nextBaseUrl } : null; - } - } catch { - // treat non-URL input as a raw handoff grant - } - - return trimmed.length >= 12 ? { grant: trimmed } : null; - }; - - const submitManualAuth = async () => { - const parsed = parseManualAuthInput(manualAuthInput()); - if (!parsed || authBusy()) { - if (!parsed) { - setAuthError(tr("den.error_paste_valid_code")); - } - return; - } - - const nextBaseUrl = parsed.baseUrl ?? baseUrl(); - - setAuthBusy(true); - setAuthError(null); - setStatusMessage(tr("den.signing_in")); - - try { - const result = await createDenClient({ - baseUrl: nextBaseUrl, - apiBaseUrl: readDenSettings().apiBaseUrl, - }).exchangeDesktopHandoff(parsed.grant); - if (!result.token) { - throw new Error(tr("den.error_no_token")); - } - - if (props.developerMode) { - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - } - - writeDenSettings({ - baseUrl: nextBaseUrl, - authToken: result.token, - activeOrgId: null, - activeOrgSlug: null, - activeOrgName: null, - }); - - setManualAuthInput(""); - setManualAuthOpen(false); - dispatchDenSessionUpdated({ - status: "success", - baseUrl: nextBaseUrl, - token: result.token, - user: result.user, - email: result.user?.email ?? null, - }); - } catch (error) { - dispatchDenSessionUpdated({ - status: "error", - message: - error instanceof Error - ? error.message - : tr("den.error_signin_failed"), - }); - } finally { - setAuthBusy(false); - } - }; - - const clearSessionState = () => { - setUser(null); - setOrgs([]); - setWorkers([]); - setActiveOrgId(""); - setOrgsError(null); - setWorkersError(null); - setTemplateActionError(null); - setSkillHubActionError(null); - setProviderActionError(null); - setSkillHubActionKind(null); - setProviderActionKind(null); - }; - - const clearSignedInState = (message?: string | null) => { - clearDenSession({ includeBaseUrls: !props.developerMode }); - clearDenTemplateCache(); - const nextBaseUrl = readDenSettings().baseUrl || DEFAULT_DEN_BASE_URL; - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - setAuthToken(""); - setOpeningWorkerId(null); - setOpeningTemplateId(null); - setSkillHubActionId(null); - setProviderActionId(null); - setSkillHubActionKind(null); - setProviderActionKind(null); - clearSessionState(); - setBaseUrlError(null); - setAuthError(null); - setStatusMessage(message ?? null); - }; - - const applyBaseUrl = async () => { - const normalized = normalizeDenBaseUrl(baseUrlDraft()); - if (!normalized) { - setBaseUrlError(tr("den.error_base_url")); - return; - } - - const resolved = resolveDenBaseUrls(normalized); - setBaseUrlBusy(true); - - try { - await setDenBootstrapConfig({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - requireSignin: readDenBootstrapConfig().requireSignin, - }); - setBaseUrlError(null); - if (resolved.baseUrl === baseUrl()) { - setBaseUrlDraft(resolved.baseUrl); - return; - } - - setBaseUrl(resolved.baseUrl); - setBaseUrlDraft(resolved.baseUrl); - writeDenSettings({ - baseUrl: resolved.baseUrl, - apiBaseUrl: resolved.apiBaseUrl, - authToken: null, - activeOrgId: null, - activeOrgSlug: null, - activeOrgName: null, - }, { persistBootstrap: false }); - clearSignedInState(tr("den.status_base_url_updated")); - } catch (error) { - setBaseUrlError( - error instanceof Error ? error.message : tr("den.error_base_url"), - ); - } finally { - setBaseUrlBusy(false); - } - }; - - const refreshOrgs = async (quiet = false) => { - if (!authToken().trim()) { - setOrgs([]); - setActiveOrgId(""); - return; - } - - setOrgsBusy(true); - if (!quiet) setOrgsError(null); - - try { - const response = await client().listOrgs(); - setOrgs(response.orgs); - const current = activeOrgId().trim(); - const fallback = response.defaultOrgId ?? response.orgs[0]?.id ?? ""; - const next = response.orgs.some((org) => org.id === current) ? current : fallback; - const nextOrg = response.orgs.find((org) => org.id === next) ?? null; - - if (nextOrg && response.activeOrgId !== nextOrg.id) { - await client().setActiveOrganization({ organizationId: nextOrg.id }); - } - - setActiveOrgId(next); - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: next || null, - activeOrgSlug: nextOrg?.slug ?? null, - activeOrgName: nextOrg?.name ?? null, - }); - if (!quiet && response.orgs.length > 0) { - setStatusMessage( - t("den.status_loaded_orgs", currentLocale(), { count: response.orgs.length, plural: response.orgs.length === 1 ? "" : "s" }), - ); - } - } catch (error) { - setOrgsError(error instanceof Error ? error.message : tr("den.error_load_orgs")); - } finally { - setOrgsBusy(false); - } - }; - - const switchActiveOrg = async (nextId: string) => { - const nextOrg = orgs().find((org) => org.id === nextId) ?? null; - if (!nextOrg || nextId === activeOrgId()) { - return; - } - - setOrgsBusy(true); - setOrgsError(null); - try { - await client().setActiveOrganization({ organizationId: nextId }); - setActiveOrgId(nextId); - writeDenSettings({ - baseUrl: baseUrl(), - authToken: authToken() || null, - activeOrgId: nextId || null, - activeOrgSlug: nextOrg.slug, - activeOrgName: nextOrg.name, - }); - setStatusMessage( - t("den.org_switched", currentLocale(), { name: nextOrg.name }), - ); - } catch (error) { - setOrgsError(error instanceof Error ? error.message : tr("den.error_load_orgs")); - } finally { - setOrgsBusy(false); - } - }; - - const refreshWorkers = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - setWorkers([]); - return; - } - - setWorkersBusy(true); - if (!quiet) setWorkersError(null); - - try { - const nextWorkers = await client().listWorkers(orgId, 20); - setWorkers(nextWorkers); - if (!quiet) { - setStatusMessage( - nextWorkers.length > 0 - ? t("den.status_loaded_workers", currentLocale(), { count: nextWorkers.length, plural: nextWorkers.length === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_workers", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - setWorkersError(error instanceof Error ? error.message : tr("den.error_load_workers")); - } finally { - setWorkersBusy(false); - } - }; - - const refreshTemplates = async (quiet = false) => { - const orgSlug = activeOrg()?.slug?.trim() ?? ""; - if (!authToken().trim() || !orgSlug) { - return; - } - - setTemplateActionError(null); - - try { - const nextTemplates = await loadDenTemplateCache( - { - baseUrl: baseUrl(), - token: authToken(), - orgSlug, - }, - { force: true }, - ); - if (!quiet) { - setStatusMessage( - nextTemplates.length > 0 - ? t("den.status_loaded_templates", currentLocale(), { count: nextTemplates.length, plural: nextTemplates.length === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_templates", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - if (!quiet) { - setTemplateActionError(error instanceof Error ? error.message : tr("den.error_load_templates")); - } - } - }; - - const refreshSkillHubs = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setSkillHubsBusy(true); - if (!quiet) setSkillHubActionError(null); - - try { - await extensions.refreshCloudOrgSkillHubs({ force: true }); - if (!quiet) { - const count = extensions.cloudOrgSkillHubs().length; - setStatusMessage( - count > 0 - ? `Loaded ${count} cloud skill hub${count === 1 ? "" : "s"} for ${activeOrg()?.name ?? tr("den.active_org_title")}.` - : `No cloud skill hubs are available for ${activeOrg()?.name ?? tr("den.active_org_title")}.`, - ); - } - } catch (error) { - if (!quiet) { - setSkillHubActionError( - error instanceof Error ? error.message : "Failed to load cloud skill hubs.", - ); - } - } finally { - setSkillHubsBusy(false); - } - }; - - const refreshSkills = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setSkillsBusy(true); - if (!quiet) setSkillActionError(null); - - try { - await extensions.refreshCloudOrgSkills({ force: true }); - if (!quiet) { - const count = extensions.cloudOrgSkills().length; - setStatusMessage( - count > 0 - ? t("den.status_loaded_skills", currentLocale(), { count, plural: count === 1 ? "" : "s", name: activeOrg()?.name ?? tr("den.active_org_title") }) - : t("den.status_no_skills", currentLocale(), { name: activeOrg()?.name ?? tr("den.active_org_title") }), - ); - } - } catch (error) { - if (!quiet) { - setSkillActionError(error instanceof Error ? error.message : tr("den.error_load_skills")); - } - } finally { - setSkillsBusy(false); - } - }; - - const refreshProviders = async (quiet = false) => { - const orgId = activeOrgId().trim(); - if (!authToken().trim() || !orgId) { - return; - } - - setProvidersBusy(true); - setProviderActionError(null); - - try { - const items = await props.refreshCloudOrgProviders({ force: !quiet }); - if (!quiet) { - setStatusMessage( - items.length > 0 - ? `Loaded ${items.length} cloud provider${items.length === 1 ? "" : "s"} for ${activeOrg()?.name ?? tr("den.active_org_title")}.` - : `No cloud providers are available for ${activeOrg()?.name ?? tr("den.active_org_title")}.`, - ); - } - } catch (error) { - if (!quiet) { - setProviderActionError( - error instanceof Error ? error.message : "Failed to load cloud providers.", - ); - } - } finally { - setProvidersBusy(false); - } - }; - - createEffect(() => { - const token = authToken().trim(); - const currentBaseUrl = baseUrl(); - let cancelled = false; - - if (!token) { - setSessionBusy(false); - clearSessionState(); - setAuthError(null); - return; - } - - setSessionBusy(true); - setAuthError(null); - - void createDenClient({ - baseUrl: currentBaseUrl, - apiBaseUrl: readDenSettings().apiBaseUrl, - token, - }) - .getSession() - .then((nextUser) => { - if (cancelled) return; - setUser(nextUser); - setStatusMessage(t("den.status_signed_in_as", currentLocale(), { email: nextUser.email })); - }) - .catch((error) => { - if (cancelled) return; - if (error instanceof DenApiError && error.status === 401) { - clearSignedInState(); - } else { - clearSessionState(); - } - setAuthError( - error instanceof Error ? error.message : tr("den.error_no_session"), - ); - }) - .finally(() => { - if (!cancelled) setSessionBusy(false); - }); - - return () => { - cancelled = true; - }; - }); - - createEffect(() => { - if (!user()) return; - void refreshOrgs(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshWorkers(true); - }); - - createEffect(() => { - if (!user() || !activeOrg()?.slug?.trim()) return; - void refreshTemplates(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshSkillHubs(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshSkills(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void refreshProviders(true); - }); - - createEffect(() => { - if (!user() || !activeOrgId().trim()) return; - void props.runCloudProviderSync("settings_cloud_opened"); - }); - - createEffect(() => { - const handler = (event: Event) => { - const customEvent = event as CustomEvent; - const nextSettings = readDenSettings(); - const nextBaseUrl = - customEvent.detail?.baseUrl?.trim() || - nextSettings.baseUrl || - DEFAULT_DEN_BASE_URL; - const nextToken = - customEvent.detail?.token?.trim() || - nextSettings.authToken?.trim() || - ""; - setBaseUrl(nextBaseUrl); - setBaseUrlDraft(nextBaseUrl); - setAuthToken(nextToken); - setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); - if (customEvent.detail?.status === "success") { - clearSessionState(); - if (customEvent.detail.user) { - setUser(customEvent.detail.user); - } - setAuthError(null); - setSessionBusy(false); - setStatusMessage( - customEvent.detail.email?.trim() - ? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() }) - : tr("den.status_cloud_signin_done"), - ); - } else if (customEvent.detail?.status === "error") { - setAuthError( - customEvent.detail.message?.trim() || - tr("den.error_signin_failed"), - ); - } - }; - - window.addEventListener( - denSessionUpdatedEvent, - handler as EventListener, - ); - return () => - window.removeEventListener( - denSessionUpdatedEvent, - handler as EventListener, - ); - }); - - createEffect(() => { - const handler = () => { - const nextSettings = readDenSettings(); - setBaseUrl(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setAuthToken(nextSettings.authToken?.trim() || ""); - setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); - }; - - window.addEventListener(denSettingsChangedEvent, handler as EventListener); - return () => - window.removeEventListener( - denSettingsChangedEvent, - handler as EventListener, - ); - }); - - const signOut = async () => { - if (authBusy()) return; - - setAuthBusy(true); - try { - if (authToken().trim()) { - await client().signOut(); - } - } catch { - // ignore remote sign out failures - } finally { - setAuthBusy(false); - } - - clearSignedInState(tr("den.status_signed_out")); - }; - - const handleOpenWorker = async (workerId: string, workerName: string) => { - const orgId = activeOrgId().trim(); - if (!orgId) { - setWorkersError(tr("den.error_choose_org")); - return; - } - - setOpeningWorkerId(workerId); - setWorkersError(null); - - try { - const tokens = await client().getWorkerTokens(workerId, orgId); - const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = - tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; - if (!openworkUrl || !accessToken) { - throw new Error(tr("den.error_worker_not_ready")); - } - - const ok = await props.connectRemoteWorkspace({ - openworkHostUrl: openworkUrl, - openworkToken: accessToken, - directory: null, - displayName: workerName, - }); - if (!ok) { - throw new Error(t("den.error_open_worker", currentLocale(), { name: workerName })); - } - - setStatusMessage(t("den.status_opened_worker", currentLocale(), { name: workerName })); - } catch (error) { - setWorkersError( - error instanceof Error ? error.message : t("den.error_open_worker_fallback", currentLocale(), { name: workerName }), - ); - } finally { - setOpeningWorkerId(null); - } - }; - - const handleOpenTemplate = async (template: DenTemplate) => { - if (openingTemplateId()) return; - - setOpeningTemplateId(template.id); - setTemplateActionError(null); - - try { - await props.openTeamBundle({ - templateId: template.id, - name: template.name, - templateData: template.templateData, - organizationName: activeOrg()?.name ?? null, - }); - const orgName = activeOrg()?.name; - setStatusMessage( - orgName - ? t("den.status_opened_template", currentLocale(), { name: template.name, org: orgName }) - : t("den.status_opened_template_fallback", currentLocale(), { name: template.name }), - ); - } catch (error) { - setTemplateActionError(error instanceof Error ? error.message : t("den.error_open_template", currentLocale(), { name: template.name })); - } finally { - setOpeningTemplateId(null); - } - }; - - const handleImportSkillHub = async (hubId: string) => { - const hub = extensions.cloudOrgSkillHubs().find((entry) => entry.id === hubId); - if (!hub || skillHubActionId()) return; - - setSkillHubActionId(hub.id); - setSkillHubActionKind("import"); - setSkillHubActionError(null); - - try { - const result = await extensions.importCloudOrgSkillHub(hub); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to import ${hub.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleRemoveSkillHub = async (hubId: string) => { - const imported = skillHubImports()[hubId]; - if (!imported || skillHubActionId()) return; - - setSkillHubActionId(hubId); - setSkillHubActionKind("remove"); - setSkillHubActionError(null); - - try { - const result = await extensions.removeCloudOrgSkillHub(hubId); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to remove ${imported.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleSyncSkillHub = async (hubId: string) => { - const hub = extensions.cloudOrgSkillHubs().find((entry) => entry.id === hubId); - if (!hub || skillHubActionId()) return; - - setSkillHubActionId(hub.id); - setSkillHubActionKind("sync"); - setSkillHubActionError(null); - - try { - const result = await extensions.syncCloudOrgSkillHub(hub); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillHubActionError(error instanceof Error ? error.message : `Failed to sync ${hub.name}.`); - } finally { - setSkillHubActionId(null); - setSkillHubActionKind(null); - } - }; - - const handleImportSkill = async (cloudSkillId: string, title: string) => { - const skill = extensions.cloudOrgSkills().find((entry) => entry.id === cloudSkillId); - if (!skill || skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("import"); - setSkillActionError(null); - - try { - const result = await extensions.installCloudOrgSkill(skill); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.import_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleRemoveSkill = async (cloudSkillId: string, title: string) => { - if (skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("remove"); - setSkillActionError(null); - - try { - const result = await extensions.removeCloudOrgSkill(cloudSkillId); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.remove_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleSyncSkill = async (cloudSkillId: string, title: string) => { - const skill = extensions.cloudOrgSkills().find((entry) => entry.id === cloudSkillId); - if (!skill || skillActionId()) return; - - setSkillActionId(cloudSkillId); - setSkillActionKind("sync"); - setSkillActionError(null); - - try { - const result = await extensions.syncCloudOrgSkill(skill); - if (!result.ok) { - throw new Error(result.message); - } - setStatusMessage(`${result.message} ${t("den.reload_workspace")}`); - } catch (error) { - setSkillActionError(error instanceof Error ? error.message : t("den.sync_skill_failed", undefined, { name: title })); - } finally { - setSkillActionId(null); - setSkillActionKind(null); - } - }; - - const handleImportProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("import"); - setProviderActionError(null); - - try { - const message = await props.connectCloudProvider(cloudProviderId); - setStatusMessage(`${message || t("den.imported_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.import_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const handleRemoveProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("remove"); - setProviderActionError(null); - - try { - const message = await props.removeCloudProvider(cloudProviderId); - setStatusMessage(`${message || t("den.removed_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.remove_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const handleSyncProvider = async (cloudProviderId: string, providerName: string) => { - if (providerActionId()) return; - - setProviderActionId(cloudProviderId); - setProviderActionKind("sync"); - setProviderActionError(null); - - try { - await props.connectCloudProvider(cloudProviderId); - setStatusMessage(`${t("den.synced_provider", undefined, { name: providerName })} ${t("den.reload_workspace")}`); - } catch (error) { - setProviderActionError(error instanceof Error ? error.message : t("den.sync_provider_failed", undefined, { name: providerName })); - } finally { - setProviderActionId(null); - setProviderActionKind(null); - } - }; - - const formatTemplateTimestamp = (value: string | null) => { - if (!value) return tr("dashboard.recently_updated"); - const date = new Date(value); - if (Number.isNaN(date.getTime())) return tr("dashboard.recently_updated"); - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(date); - }; - - const templateCreatorLabel = (template: DenTemplate) => { - const creator = template.creator; - if (!creator) return tr("dashboard.unknown_creator"); - return creator.name?.trim() || creator.email?.trim() || tr("dashboard.unknown_creator"); - }; - - const settingsPanelClass = - "ow-soft-card rounded-[28px] p-5 md:p-6"; - const settingsPanelSoftClass = - "ow-soft-card-quiet rounded-2xl p-4"; - // Keep Cloud badges and controls on design-language tokens so dark mode preserves contrast. - const headerBadgeClass = - "inline-flex min-h-8 items-center gap-2 rounded-xl border border-dls-border bg-dls-hover px-3 text-[13px] font-medium text-dls-text shadow-sm"; - const headerStatusBadgeClass = - "inline-flex min-h-10 min-w-[132px] items-center justify-center gap-2 rounded-2xl border border-dls-border bg-dls-hover px-4 text-center text-sm font-medium text-dls-text shadow-sm"; - const sectionPillClass = - "inline-flex items-center gap-1.5 rounded-full border border-dls-border bg-dls-hover px-2.5 py-1 text-[11px] font-medium text-dls-secondary"; - const softNoticeClass = - "rounded-xl border border-dls-border bg-dls-hover px-3 py-2 text-xs text-dls-secondary"; - const quietControlClass = - "border border-dls-border bg-dls-hover text-dls-text shadow-sm"; - - return ( -
- -
-
-
- - {tr("den.cloud_section_title")} -
-
-
- {tr("den.cloud_section_desc")} -
-
- {tr("den.cloud_sleep_hint")} -
-
-
-
- - {summaryLabel()} -
-
- - -
- setBaseUrlDraft(event.currentTarget.value)} - placeholder={DEFAULT_DEN_BASE_URL} - hint={tr("den.cloud_control_plane_url_hint")} - disabled={authBusy() || sessionBusy()} - /> -
- - - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
- - - {(value) => ( -
- {value()} -
- )} -
-
- }> - setBaseUrlDraft(baseUrl())} - onApplyBaseUrl={() => { - void applyBaseUrl(); - }} - onOpenControlPlane={openControlPlane} - onOpenBrowserAuth={openBrowserAuth} - onToggleManualAuth={() => { - setManualAuthOpen((value) => !value); - setAuthError(null); - }} - onManualAuthInput={setManualAuthInput} - onSubmitManualAuth={() => void submitManualAuth()} - /> - - - -
-
-
-
{tr("den.cloud_account_title")}
-
- {tr("den.cloud_account_hint")} -
-
- -
-
-
-
- {user()?.name || user()?.email} -
-
- {user()?.email} -
-
- -
- -
-
-
{tr("den.active_org_title")}
-
- {tr("den.active_org_hint")} -
-
-
- - -
-
-
- - - {(value) => ( -
- {value()} -
- )} -
-
- -
-
-
-
- - {tr("den.cloud_skills_title")} -
-
- {tr("den.cloud_skills_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_cloud_skills")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => skillActionId() === row.cloudSkillId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (skillActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - - return ( -
-
-
- {row.title} - - {t("skills.cloud_hub_label", currentLocale(), { name: row.skill?.hubName ?? "" })} - - - {tr("skills.cloud_shared_org")} - - - {tr("skills.cloud_shared_public")} - - - {tr("den.private_badge")} - - - {t("den.installed_name_badge", currentLocale(), { name: row.installedName ?? "" })} - - - - {row.status === "installed" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "available" - ? t("den.cloud_skill_detail", currentLocale(), { title: row.title }) - : row.status === "installed" - ? t("den.cloud_skill_imported_detail", currentLocale(), { - name: row.installedName ?? row.title, - }) - : row.status === "out_of_sync" - ? t("den.cloud_skill_sync_detail", currentLocale(), { - name: row.installedName ?? row.title, - }) - : t("den.cloud_skill_removed_detail", currentLocale(), { - name: row.installedName ?? row.title, - })} -
-
-
- - - - -
-
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.cloud_workers_title")} -
-
- {tr("den.cloud_workers_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- {tr("den.no_cloud_workers")} -
-
- -
- - {(worker) => { - const status = createMemo(() => workerStatusMeta(worker.status, tr)); - return ( -
-
-
- - {worker.workerName} - - - {status().label} - - - - {tr("den.worker_mine_badge")} - - -
-
- {worker.provider ? t("den.worker_provider_label", currentLocale(), { provider: worker.provider }) : tr("den.worker_secondary_cloud")} - - {(value) => · {value()}} - -
-
- -
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.team_templates_title")} -
-
- {tr("den.team_templates_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_team_templates")} - -
-
- -
- - {(template) => { - const isMine = () => template.creator?.userId === user()?.id; - const opening = () => openingTemplateId() === template.id; - return ( -
-
-
- - {template.name} - - - {tr("den.team_template_badge")} - - - - {tr("den.worker_mine_badge")} - - -
-
- by {templateCreatorLabel(template)} · {formatTemplateTimestamp(template.createdAt)} -
-
- -
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.skill_hubs_title")} -
-
- {tr("den.skill_hubs_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_skill_hubs")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => skillHubActionId() === row.hubId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (skillHubActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - return ( -
-
-
- {row.name} - - {t("den.skill_hub_skills_badge", currentLocale(), { - count: row.hub?.skills.length ?? row.importedSkillCount, - })} - - - - {row.status === "imported" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "available" - ? t("den.skill_hub_detail", currentLocale(), { count: row.liveSkillCount }) - : row.status === "imported" - ? t("den.skill_hub_imported_detail", currentLocale(), { - count: row.importedSkillCount, - }) - : row.status === "out_of_sync" - ? t("den.skill_hub_sync_detail", currentLocale(), { - liveCount: row.liveSkillCount, - importedCount: row.importedSkillCount, - }) - : t("den.skill_hub_removed_detail", currentLocale(), { - importedCount: row.importedSkillCount, - })} -
-
-
- - - - -
-
- ); - }} -
-
-
- -
-
-
-
- - {tr("den.cloud_providers_title")} -
-
- {tr("den.cloud_providers_hint")} -
-
-
-
- - {activeOrgName()} -
- -
-
- - - {(value) => ( -
- {value()} -
- )} -
- - -
- - {tr("den.no_cloud_providers")} - -
-
- -
- - {(row) => { - const actionBusy = createMemo(() => providerActionId() === row.cloudProviderId); - const actionLabel = createMemo(() => { - if (!actionBusy()) return null; - switch (providerActionKind()) { - case "import": - return tr("den.importing"); - case "sync": - return tr("den.syncing"); - default: - return tr("den.removing"); - } - }); - return ( -
-
-
- {row.name} - - - {row.provider?.providerId ?? row.imported?.providerId} - - - {tr("den.credentials_ready_badge")} - - - - {row.status === "imported" - ? tr("den.imported_badge") - : row.status === "out_of_sync" - ? tr("den.out_of_sync_badge") - : tr("den.removed_from_cloud_badge")} - - -
-
- {row.status === "removed_from_cloud" - ? t("den.cloud_provider_removed_detail", currentLocale(), { - providerId: row.imported?.providerId ?? row.name, - }) - : row.status === "out_of_sync" - ? t("den.cloud_provider_sync_detail", currentLocale(), { - count: row.provider?.models.length ?? 0, - source: row.provider?.source === "custom" ? "custom" : "managed", - }) - : t("den.cloud_provider_detail", currentLocale(), { - count: row.provider?.models.length ?? 0, - source: row.provider?.source === "custom" ? "custom" : "managed", - })} -
-
-
- - - - -
-
- ); - }} -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/flyout-item.tsx b/apps/app/src/app/components/flyout-item.tsx deleted file mode 100644 index c8855c0b..00000000 --- a/apps/app/src/app/components/flyout-item.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Show, createSignal, onMount } from "solid-js"; -import { Check, FileText, Folder } from "lucide-solid"; - -export type FlyoutProps = { - item: { - id: string; - rect: { top: number; left: number; width: number; height: number }; - targetRect: { top: number; left: number; width: number; height: number }; - label: string; - icon: "file" | "check" | "folder"; - }; -}; - -export default function FlyoutItem(props: FlyoutProps) { - const [active, setActive] = createSignal(false); - onMount(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setActive(true); - }); - }); - }); - - return ( -
- - - - - - - - - - {props.item.label} -
- ); -} diff --git a/apps/app/src/app/components/mcp-auth-modal.tsx b/apps/app/src/app/components/mcp-auth-modal.tsx deleted file mode 100644 index 8d0b04a1..00000000 --- a/apps/app/src/app/components/mcp-auth-modal.tsx +++ /dev/null @@ -1,926 +0,0 @@ -import { For, Show, createEffect, createSignal, on, onCleanup } from "solid-js"; -import { CheckCircle2, Loader2, RefreshCcw, X } from "lucide-solid"; -import Button from "./button"; -import TextInput from "./text-input"; -import type { Client } from "../types"; -import type { McpDirectoryInfo } from "../constants"; -import { unwrap } from "../lib/opencode"; -import { opencodeMcpAuth } from "../lib/tauri"; -import { validateMcpServerName } from "../mcp"; -import { t, type Language } from "../../i18n"; -import { isTauriRuntime, normalizeDirectoryPath } from "../utils"; - -const MCP_AUTH_POLL_INTERVAL_MS = 2_000; -const MCP_AUTH_TIMEOUT_MS = 90_000; -const MCP_AUTH_DISCOVERY_TIMEOUT_MS = 15_000; - -export type McpAuthModalProps = { - open: boolean; - onClose: () => void; - onComplete: () => void | Promise; - onReloadEngine?: () => void | Promise; - reloadRequired?: boolean; - reloadBlocked?: boolean; - activeSessions?: Array<{ id: string; title: string }>; - isRemoteWorkspace?: boolean; - client: Client | null; - entry: McpDirectoryInfo | null; - projectDir: string; - language: Language; - onForceStopSession?: (sessionID: string) => void | Promise; -}; - -export default function McpAuthModal(props: McpAuthModalProps) { - const translate = (key: string, replacements?: Record) => { - let result = t(key, props.language); - if (replacements) { - Object.entries(replacements).forEach(([placeholder, value]) => { - result = result.replace(`{${placeholder}}`, value); - }); - } - return result; - }; - - const [loading, setLoading] = createSignal(false); - const [error, setError] = createSignal(null); - const [needsReload, setNeedsReload] = createSignal(false); - const [alreadyConnected, setAlreadyConnected] = createSignal(false); - const [authInProgress, setAuthInProgress] = createSignal(false); - const [statusChecking, setStatusChecking] = createSignal(false); - const [reloadNotice, setReloadNotice] = createSignal(null); - const [authorizationUrl, setAuthorizationUrl] = createSignal(null); - const [callbackInput, setCallbackInput] = createSignal(""); - const [manualAuthBusy, setManualAuthBusy] = createSignal(false); - const [cliAuthBusy, setCliAuthBusy] = createSignal(false); - const [cliAuthResult, setCliAuthResult] = createSignal(null); - const [authUrlCopied, setAuthUrlCopied] = createSignal(false); - const [resolvedDir, setResolvedDir] = createSignal(""); - const [awaitingReload, setAwaitingReload] = createSignal(false); - const [reloadStarting, setReloadStarting] = createSignal(false); - const [reloadSatisfied, setReloadSatisfied] = createSignal(false); - const [forceStopBusySessionID, setForceStopBusySessionID] = createSignal(null); - - let statusPoll: number | null = null; - let authCopyTimeout: number | null = null; - - const stopStatusPolling = () => { - if (statusPoll !== null) { - window.clearInterval(statusPoll); - statusPoll = null; - } - }; - - onCleanup(() => stopStatusPolling()); - - createEffect(() => { - const normalized = normalizeDirectoryPath(props.projectDir ?? ""); - const collapsed = normalized.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); - setResolvedDir(collapsed); - }); - - onCleanup(() => { - if (authCopyTimeout !== null) { - window.clearTimeout(authCopyTimeout); - authCopyTimeout = null; - } - }); - - const openAuthorizationUrl = async (url: string) => { - if (isTauriRuntime()) { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl(url); - return; - } - - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); - } - }; - - const handleCopyAuthorizationUrl = async () => { - const url = authorizationUrl(); - if (!url) return; - try { - await navigator.clipboard.writeText(url); - setAuthUrlCopied(true); - if (authCopyTimeout !== null) { - window.clearTimeout(authCopyTimeout); - } - authCopyTimeout = window.setTimeout(() => { - setAuthUrlCopied(false); - authCopyTimeout = null; - }, 2000); - } catch { - // ignore - } - }; - - const fetchMcpStatus = async (slug: string) => { - const entry = props.entry; - const client = props.client; - if (!entry || !client) return null; - - try { - const directory = resolvedDir().trim(); - if (!directory) return null; - const result = await client.mcp.status({ directory }); - const status = result.data?.[slug] as { status?: string; error?: string } | undefined; - return status ?? null; - } catch { - return null; - } - }; - - const resolveDirectory = async () => { - const current = resolvedDir().trim(); - if (current) return current; - const client = props.client; - if (!client) return ""; - try { - const info = unwrap(await client.path.get()); - const next = normalizeDirectoryPath(info.directory ?? ""); - const collapsed = next.replace(/^\/private\/tmp(?=\/|$)/, "/tmp"); - if (collapsed) { - setResolvedDir(collapsed); - } - return collapsed; - } catch { - return ""; - } - }; - - const resolveSlug = (name: string) => validateMcpServerName(name).toLowerCase().replace(/[^a-z0-9]+/g, "-"); - - const waitForMcpAvailability = async (slug: string) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < MCP_AUTH_DISCOVERY_TIMEOUT_MS) { - const status = await fetchMcpStatus(slug); - if (status) return status; - await new Promise((resolve) => window.setTimeout(resolve, 500)); - } - return null; - }; - - const startStatusPolling = (slug: string) => { - if (typeof window === "undefined") return; - stopStatusPolling(); - const startedAt = Date.now(); - statusPoll = window.setInterval(async () => { - if (Date.now() - startedAt >= MCP_AUTH_TIMEOUT_MS) { - stopStatusPolling(); - setError(translate("mcp.auth.request_timed_out")); - return; - } - - const status = await fetchMcpStatus(slug); - if (status?.status === "connected") { - setAlreadyConnected(true); - setError(null); - stopStatusPolling(); - } - }, MCP_AUTH_POLL_INTERVAL_MS); - }; - - const startAuth = async (forceRetry = false, allowAutoReload = true) => { - const entry = props.entry; - const client = props.client; - - if (!entry || !client) return; - - const isRemoteWorkspace = !!props.isRemoteWorkspace; - - let slug = ""; - try { - slug = resolveSlug(entry.name); - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth"); - setError(message); - setLoading(false); - setAuthInProgress(false); - return; - } - - if (!forceRetry && authInProgress()) { - return; - } - - setError(null); - setNeedsReload(false); - setAlreadyConnected(false); - stopStatusPolling(); - setAuthorizationUrl(null); - setCallbackInput(""); - setReloadNotice(null); - setLoading(true); - setAuthInProgress(true); - - try { - const directory = await resolveDirectory(); - if (!directory) { - setError(translate("mcp.pick_workspace_first")); - return; - } - - const statusEntry = await fetchMcpStatus(slug); - if (props.reloadRequired && !reloadSatisfied() && !statusEntry) { - setNeedsReload(true); - setReloadNotice( - props.reloadBlocked - ? translate("mcp.auth.reload_blocked") - : translate("mcp.auth.reload_notice") - ); - return; - } - - if (statusEntry?.status === "connected") { - setAlreadyConnected(true); - return; - } - - if (!isRemoteWorkspace) { - const result = await client.mcp.auth.authenticate({ - name: slug, - directory, - }); - const status = unwrap(result) as { status?: string; error?: string }; - - if (status.status === "connected") { - setAlreadyConnected(true); - await props.onComplete(); - return; - } - - if (status.status === "needs_client_registration") { - setError(status.error ?? translate("mcp.auth.client_registration_required")); - } else if (status.status === "disabled") { - setError(translate("mcp.auth.server_disabled")); - } else if (status.status === "failed") { - setError(status.error ?? translate("mcp.auth.oauth_failed")); - } else { - setError(translate("mcp.auth.authorization_still_required")); - } - return; - } - - const authResult = await client.mcp.auth.start({ - name: slug, - directory, - }); - const auth = unwrap(authResult) as { authorizationUrl?: string }; - - if (!auth.authorizationUrl) { - setAlreadyConnected(true); - return; - } - - setAuthorizationUrl(auth.authorizationUrl); - await openAuthorizationUrl(auth.authorizationUrl); - startStatusPolling(slug); - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth"); - - if (message.toLowerCase().includes("does not support oauth")) { - const serverSlug = props.entry?.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "server"; - const canAutoReload = - allowAutoReload && !props.isRemoteWorkspace && !props.reloadBlocked && Boolean(props.onReloadEngine); - - if (canAutoReload && props.onReloadEngine) { - await props.onReloadEngine(); - await startAuth(true, false); - return; - } - - if (props.reloadRequired && !reloadSatisfied()) { - setReloadNotice( - props.reloadBlocked - ? translate("mcp.auth.reload_blocked") - : translate("mcp.auth.reload_notice") - ); - } else { - setError( - `${message}\n\n` + translate("mcp.auth.oauth_not_supported_hint", { server: serverSlug }) - ); - } - setNeedsReload(true); - } else if (message.toLowerCase().includes("not found") || message.toLowerCase().includes("unknown")) { - setNeedsReload(true); - setError(translate("mcp.auth.try_reload_engine", { message })); - } else { - setError(message); - } - } finally { - setLoading(false); - setAuthInProgress(false); - } - }; - - const isInvalidRefreshToken = () => { - const message = error(); - if (!message) return false; - const normalized = message.toLowerCase(); - return ( - normalized.includes("invalidgranterror") || - normalized.includes("invalid refresh token") || - normalized.includes("invalid_refresh_token") - ); - }; - - const handleCliReauth = async () => { - const entry = props.entry; - if (!entry || cliAuthBusy()) return; - if (props.isRemoteWorkspace) return; - if (!isTauriRuntime()) return; - - setCliAuthBusy(true); - setCliAuthResult(null); - - try { - const result = await opencodeMcpAuth(props.projectDir, entry.name); - if (result.ok) { - setError(null); - setNeedsReload(true); - setReloadNotice(translate("mcp.auth.oauth_completed_reload")); - } else { - setCliAuthResult(result.stderr || result.stdout || translate("mcp.auth.reauth_failed")); - } - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.reauth_failed"); - setCliAuthResult(message); - } finally { - setCliAuthBusy(false); - } - }; - - // Start the OAuth flow when modal opens with an entry - createEffect( - on( - () => [props.open, props.entry, props.client, props.reloadRequired] as const, - ([isOpen, entry, client, reloadRequired], previous) => { - if (!isOpen || !entry || !client) { - return; - } - const previousEntry = previous?.[1]; - if (!previous || previousEntry?.name !== entry.name || !previous?.[0]) { - setReloadSatisfied(false); - } - if (reloadRequired && !reloadSatisfied()) { - setAwaitingReload(true); - return; - } - // Only start auth on initial open, not on every prop change - startAuth(false); - }, - { defer: true } // Defer to avoid double-firing on mount - ) - ); - - createEffect(() => { - if (!props.open || !awaitingReload()) return; - if (props.reloadBlocked) return; - const reloadEngine = props.onReloadEngine; - const entry = props.entry; - if (!reloadEngine || !entry || reloadStarting()) return; - - void (async () => { - setReloadStarting(true); - setError(null); - setNeedsReload(false); - setReloadNotice(null); - try { - await reloadEngine(); - if (!props.open) return; - const slug = resolveSlug(entry.name); - const status = await waitForMcpAvailability(slug); - if (!status) { - setAwaitingReload(false); - setNeedsReload(true); - setReloadNotice( - props.reloadBlocked - ? translate("mcp.auth.reload_blocked") - : translate("mcp.auth.reload_notice") - ); - return; - } - setReloadSatisfied(true); - setAwaitingReload(false); - startAuth(false, false); - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.reload_failed"); - setAwaitingReload(false); - setNeedsReload(true); - setError(message); - } finally { - setReloadStarting(false); - } - })(); - }); - - const handleRetry = () => { - startAuth(true); - }; - - const handleReopenBrowser = () => { - handleRetry(); - }; - - const handleReloadAndRetry = async () => { - if (!props.onReloadEngine) return; - if (props.isRemoteWorkspace && typeof window !== "undefined") { - const proceed = window.confirm(translate("mcp.auth.reload_remote_confirm")); - if (!proceed) return; - } - await props.onReloadEngine(); - startAuth(true); - }; - - const handleForceStopSession = async (sessionID: string) => { - if (!props.onForceStopSession || forceStopBusySessionID()) return; - setForceStopBusySessionID(sessionID); - try { - await props.onForceStopSession(sessionID); - } finally { - setForceStopBusySessionID(null); - } - }; - - const handleClose = () => { - setError(null); - setLoading(false); - setAlreadyConnected(false); - setNeedsReload(false); - setAuthInProgress(false); - setStatusChecking(false); - setAuthorizationUrl(null); - setCallbackInput(""); - setManualAuthBusy(false); - setReloadNotice(null); - setCliAuthBusy(false); - setCliAuthResult(null); - setAwaitingReload(false); - setReloadStarting(false); - setReloadSatisfied(false); - setForceStopBusySessionID(null); - stopStatusPolling(); - props.onClose(); - }; - - const isBusy = () => loading() || statusChecking() || manualAuthBusy(); - const isPreparingReload = () => awaitingReload() || reloadStarting(); - - const parseAuthCode = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return null; - - const match = trimmed.match(/[?&]code=([^&]+)/); - if (match) { - try { - return decodeURIComponent(match[1]); - } catch { - return match[1]; - } - } - - if (/^https?:\/\//i.test(trimmed) || trimmed.includes("localhost") || trimmed.includes("127.0.0.1")) { - return null; - } - - return trimmed; - }; - - const handleManualComplete = async () => { - const entry = props.entry; - const client = props.client; - if (!entry || !client) return; - - let slug = ""; - try { - const safeName = validateMcpServerName(entry.name); - slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth"); - setError(message); - return; - } - - const code = parseAuthCode(callbackInput()); - if (!code) { - setError(translate("mcp.auth.callback_invalid")); - return; - } - - setManualAuthBusy(true); - setError(null); - stopStatusPolling(); - - try { - const directory = await resolveDirectory(); - if (!directory) { - setError(translate("mcp.pick_workspace_first")); - return; - } - - const result = await client.mcp.auth.callback({ - name: slug, - directory, - code, - }); - const status = unwrap(result) as { status?: string; error?: string }; - if (status.status === "connected") { - setAlreadyConnected(true); - setManualAuthBusy(false); - await props.onComplete(); - return; - } - - if (status.status === "needs_client_registration") { - setError(status.error ?? translate("mcp.auth.client_registration_required")); - } else if (status.status === "disabled") { - setError(translate("mcp.auth.server_disabled")); - } else if (status.status === "failed") { - setError(status.error ?? translate("mcp.auth.oauth_failed")); - } else { - setError(translate("mcp.auth.authorization_still_required")); - } - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.oauth_failed"); - setError(message); - } finally { - setManualAuthBusy(false); - } - }; - - const handleComplete = async () => { - const entry = props.entry; - const client = props.client; - if (!entry || !client) return; - - setError(null); - setStatusChecking(true); - - let slug = ""; - try { - const safeName = validateMcpServerName(entry.name); - slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - } catch (err) { - const message = err instanceof Error ? err.message : translate("mcp.auth.failed_to_start_oauth"); - setError(message); - setStatusChecking(false); - return; - } - - const statusEntry = await fetchMcpStatus(slug); - if (statusEntry?.status === "connected") { - setAlreadyConnected(true); - setStatusChecking(false); - await props.onComplete(); - return; - } - - if (statusEntry?.status === "needs_client_registration") { - setError(statusEntry.error ?? translate("mcp.auth.client_registration_required")); - } else if (statusEntry?.status === "disabled") { - setError(translate("mcp.auth.server_disabled")); - } else if (statusEntry?.status === "failed") { - setError(statusEntry.error ?? translate("mcp.auth.oauth_failed")); - } else { - setError(translate("mcp.auth.authorization_still_required")); - } - - setStatusChecking(false); - }; - - const serverName = () => props.entry?.name ?? "MCP Server"; - - return ( - -
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
-
-

- {translate("mcp.auth.connect_server", { server: serverName() })} -

-

{translate("mcp.auth.open_browser_signin")}

-
- -
- - {/* Content */} -
- -
-
- -
-
-

- {translate("mcp.auth.waiting_authorization")} -

-

- {translate("mcp.auth.follow_browser_steps")} -

- -
-
-
- - -
-
- -
-
-

- {props.reloadBlocked - ? translate("mcp.auth.waiting_for_conversation_title") - : translate("mcp.auth.applying_changes_title")} -

-

- {props.reloadBlocked - ? translate("mcp.auth.waiting_for_conversation_body") - : translate("mcp.auth.applying_changes_body")} -

-
- 0}> -
- - {(session) => ( -
- - {translate("mcp.auth.waiting_for_session", { session: session.title })} - - -
- )} -
-
-
-
-
- - -
-
-
- -
-
-

{translate("mcp.auth.already_connected")}

-

- {translate("mcp.auth.already_connected_description", { server: serverName() })} -

-
-
-

- {translate("mcp.auth.configured_previously")} -

-
-
- - -
-

{reloadNotice()}

- -
- - - - -
-
-
- - -
-

{error()}

- - -
- - - - -
-
- - -
- -
-
- - -
-

{translate("mcp.auth.invalid_refresh_token")}

- - - - - -
- {translate("mcp.auth.reauth_cli_hint", { server: serverName() })} -
-
-
- -
- {translate("mcp.auth.reauth_remote_hint")} -
-
- -
{cliAuthResult()}
-
-
-
-
-
- - -
-
- {translate("mcp.auth.manual_finish_title")} -
-
- {translate("mcp.auth.manual_finish_hint")} -
-
-
-
{translate("mcp.auth.authorization_link")}
-
- {authorizationUrl()} -
-
- -
- setCallbackInput(event.currentTarget.value)} - /> -
- {translate("mcp.auth.port_forward_hint")} -
-
- -
-
-
- - -
-
-
- 1 -
-
-

{translate("mcp.auth.step1_title")}

-

- {translate("mcp.auth.step1_description", { server: serverName() })} -

-
-
- -
-
- 2 -
-
-

{translate("mcp.auth.step2_title")}

-

- {translate("mcp.auth.step2_description")} -

-
-
- -
-
- 3 -
-
-

{translate("mcp.auth.step3_title")}

-

- {translate("mcp.auth.step3_description")} -

-
-
-
- -
-
-

{translate("mcp.auth.waiting_authorization")}

-

- {translate("mcp.auth.follow_browser_steps")} -

- -
-
-
-
- - {/* Footer */} -
- - - - - - - -
-
-
- - ); -} diff --git a/apps/app/src/app/components/model-picker-modal.tsx b/apps/app/src/app/components/model-picker-modal.tsx deleted file mode 100644 index fcddced2..00000000 --- a/apps/app/src/app/components/model-picker-modal.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; - -import { CheckCircle2, Circle, Search, X } from "lucide-solid"; -import { t } from "../../i18n"; - -import Button from "./button"; -import ProviderIcon from "./provider-icon"; -import { modelEquals } from "../utils"; -import type { ModelOption, ModelRef } from "../types"; - -export type ModelPickerModalProps = { - open: boolean; - options: ModelOption[]; - filteredOptions: ModelOption[]; - query: string; - setQuery: (value: string) => void; - target: "default" | "session"; - current: ModelRef; - onSelect: (model: ModelRef) => void; - onBehaviorChange: (model: ModelRef, value: string | null) => void; - onOpenSettings: () => void; - onClose: (options?: { restorePromptFocus?: boolean }) => void; -}; - -export default function ModelPickerModal(props: ModelPickerModalProps) { - let searchInputRef: HTMLInputElement | undefined; - const translate = (key: string, params?: Record) => t(key, undefined, params); - - type RenderedItem = - | { kind: "model"; opt: ModelOption } - | { kind: "provider"; providerID: string; title: string; matchCount: number }; - - const [activeIndex, setActiveIndex] = createSignal(0); - const optionRefs: HTMLButtonElement[] = []; - - const otherProviderLinks = createMemo(() => { - const seen = new Set(); - const items: { providerID: string; title: string; matchCount: number }[] = []; - const counts = new Map(); - - for (const opt of props.filteredOptions) { - if (opt.isConnected) continue; - counts.set(opt.providerID, (counts.get(opt.providerID) ?? 0) + 1); - if (seen.has(opt.providerID)) continue; - seen.add(opt.providerID); - items.push({ - providerID: opt.providerID, - title: opt.description ?? opt.providerID, - matchCount: 1, - }); - } - - return items.map((item) => ({ - ...item, - matchCount: counts.get(item.providerID) ?? 1, - })); - }); - - const renderedItems = createMemo(() => { - const models = props.filteredOptions.filter((opt) => opt.isConnected); - const recommended = models.filter((opt) => opt.isRecommended); - const others = models.filter((opt) => !opt.isRecommended); - - return [ - ...recommended.map((opt) => ({ kind: "model" as const, opt })), - ...others.map((opt) => ({ kind: "model" as const, opt })), - ...otherProviderLinks().map((item) => ({ kind: "provider" as const, ...item })), - ]; - }); - - const activeModelIndex = createMemo(() => { - const list = renderedItems(); - return list.findIndex( - (item) => - item.kind === "model" && - modelEquals(props.current, { - providerID: item.opt.providerID, - modelID: item.opt.modelID, - }), - ); - }); - - const recommendedOptions = createMemo(() => - renderedItems().flatMap((item, index) => - item.kind === "model" && item.opt.isRecommended ? [{ opt: item.opt, index }] : [], - ), - ); - - const otherEnabledOptions = createMemo(() => - renderedItems().flatMap((item, index) => - item.kind === "model" && !item.opt.isRecommended ? [{ opt: item.opt, index }] : [], - ), - ); - - const otherOptions = createMemo(() => - renderedItems().flatMap((item, index) => - item.kind === "provider" - ? [{ providerID: item.providerID, title: item.title, matchCount: item.matchCount, index }] - : [], - ), - ); - - const clampIndex = (next: number) => { - const last = renderedItems().length - 1; - if (last < 0) return 0; - return Math.max(0, Math.min(next, last)); - }; - - const scrollActiveIntoView = (idx: number) => { - const el = optionRefs[idx]; - if (!el) return; - el.scrollIntoView({ block: "nearest" }); - }; - - createEffect(() => { - if (!props.open) return; - requestAnimationFrame(() => { - searchInputRef?.focus(); - if (searchInputRef?.value) { - searchInputRef.select(); - } - }); - }); - - createEffect(() => { - if (!props.open) return; - const idx = activeModelIndex(); - const next = idx >= 0 ? idx : 0; - setActiveIndex(clampIndex(next)); - requestAnimationFrame(() => scrollActiveIntoView(clampIndex(next))); - }); - - createEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (!props.open) return; - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - props.onClose(); - return; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - event.stopPropagation(); - setActiveIndex((current) => { - const next = clampIndex(current + 1); - requestAnimationFrame(() => scrollActiveIntoView(next)); - return next; - }); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - event.stopPropagation(); - setActiveIndex((current) => { - const next = clampIndex(current - 1); - requestAnimationFrame(() => scrollActiveIntoView(next)); - return next; - }); - return; - } - - if (event.key === "Enter") { - if (event.isComposing || event.keyCode === 229) return; - const idx = activeIndex(); - const item = renderedItems()[idx]; - if (!item) return; - event.preventDefault(); - event.stopPropagation(); - if (item.kind === "provider") { - props.onClose({ restorePromptFocus: false }); - props.onOpenSettings(); - return; - } - props.onSelect({ providerID: item.opt.providerID, modelID: item.opt.modelID }); - } - }; - - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }); - - const renderOption = (opt: ModelOption, index: number) => { - const active = () => - modelEquals(props.current, { - providerID: opt.providerID, - modelID: opt.modelID, - }); - - return ( -
{ - optionRefs[index] = el as unknown as HTMLButtonElement; - }} - class={`group w-full text-left rounded-xl px-3 py-2.5 transition-colors cursor-pointer ${ - active() - ? "bg-gray-3 text-gray-12" - : index === activeIndex() - ? "bg-gray-2 text-gray-12" - : "text-gray-10 hover:bg-gray-1/70 hover:text-gray-11" - }`} - onMouseEnter={() => { - setActiveIndex(index); - }} - onClick={() => { - props.onSelect({ - providerID: opt.providerID, - modelID: opt.modelID, - }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - props.onSelect({ - providerID: opt.providerID, - modelID: opt.modelID, - }); - }} - > -
- -
-
- {opt.title} -
-
- {opt.description ?? opt.providerID} - - {opt.providerID}/{opt.modelID} - -
- -
{opt.footer}
-
- 0}> -
e.stopPropagation()}> - {opt.behaviorTitle}: -
e.stopPropagation()}> - - {(option) => ( - - )} - -
-
-
-
-
-
- ); - }; - - const renderProviderLink = (provider: { providerID: string; title: string; matchCount: number }, index: number) => ( -
{ - optionRefs[index] = el as unknown as HTMLButtonElement; - }} - class={`group w-full text-left rounded-xl px-3 py-2.5 transition-colors cursor-pointer ${ - index === activeIndex() - ? "bg-gray-2 text-gray-12" - : "text-gray-10 hover:bg-gray-1/70 hover:text-gray-11" - }`} - onMouseEnter={() => { - setActiveIndex(index); - }} - onClick={() => { - props.onClose({ restorePromptFocus: false }); - props.onOpenSettings(); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - props.onClose({ restorePromptFocus: false }); - props.onOpenSettings(); - }} - > -
- -
-
- {provider.title} -
-
- {translate("model_picker.connect_provider_hint")} - - {translate(provider.matchCount === 1 ? "model_picker.model_count_one" : "model_picker.model_count", { count: provider.matchCount })} - -
-
-
-
- ); - - return ( - -
-
-
-
-
-

- {translate(props.target === "default" ? "model_picker.default_model_title" : "model_picker.chat_model_title")} -

-

- {translate(props.target === "default" - ? "model_picker.default_model_desc" - : "model_picker.chat_model_desc")} -

-
- -
- -
-
- - (searchInputRef = el)} - type="text" - value={props.query} - onInput={(e) => props.setQuery(e.currentTarget.value)} - placeholder={translate("settings.search_models")} - class="w-full bg-dls-surface border border-dls-border rounded-xl py-2.5 pl-9 pr-3 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent" - /> -
- -
- {translate("settings.showing_models", { count: props.filteredOptions.length, total: props.options.length })} -
-
-
- -
- 0}> -
-
- {translate("model_picker.recommended")} -
- {({ opt, index }) => renderOption(opt, index)} -
-
- - 0}> -
-
- {translate("model_picker.other_connected_models")} -
- {({ opt, index }) => renderOption(opt, index)} -
-
- - 0}> -
-
- {translate("model_picker.more_providers")} -
- - {(provider) => renderProviderLink(provider, provider.index)} - -
-
- - -
- {translate("model_picker.no_results")} -
-
-
- -
- -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/part-view.tsx b/apps/app/src/app/components/part-view.tsx deleted file mode 100644 index 4105e0f9..00000000 --- a/apps/app/src/app/components/part-view.tsx +++ /dev/null @@ -1,1164 +0,0 @@ -import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { marked } from "marked"; -import type { Part } from "@opencode-ai/sdk/v2/client"; -import { File } from "lucide-solid"; -import { isTauriRuntime, safeStringify, summarizeStep } from "../utils"; -import { usePlatform } from "../context/platform"; -import { perfNow, recordPerfLog } from "../lib/perf-log"; - -type Props = { - part: Part; - developerMode?: boolean; - showThinking?: boolean; - tone?: "light" | "dark"; - workspaceRoot?: string; - renderMarkdown?: boolean; - markdownThrottleMs?: number; - highlightQuery?: string; -}; - -type LinkType = "url" | "file"; - -type TextSegment = - | { kind: "text"; value: string } - | { kind: "link"; value: string; href: string; type: LinkType }; - -type LinkDetectionOptions = { - allowFilePaths?: boolean; -}; - -const WEB_LINK_RE = /^(?:https?:\/\/|www\.)/i; -const FILE_URI_RE = /^file:\/\//i; -const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_PATH_RE = /^[A-Za-z]:[\\/][^\s"'`\)\]\}>]+$/; -const POSIX_PATH_RE = /^\/(?!\/)[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; -const TILDE_PATH_RE = /^~\/[^\s"'`\)\]\}>][^\s"'`\)\]\}>]*$/; -const BARE_FILENAME_RE = /^(?!\.)(?!.*\.\.)(?:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)$/; -const SAFE_PATH_CHAR_RE = /[^\s"'`\)\]\}>]/; - -const stripFileReferenceSuffix = (value: string) => { - const withoutQueryOrFragment = value.replace(/[?#].*$/, "").trim(); - if (!withoutQueryOrFragment) return ""; - return withoutQueryOrFragment.replace(/:(\d+)(?::\d+)?$/, ""); -}; - -const isWorkspaceRelativeFilePath = (value: string) => { - const stripped = stripFileReferenceSuffix(value); - if (!stripped) return false; - - const normalized = stripped.replace(/\\/g, "/"); - if (!normalized.includes("/")) return false; - if (normalized.startsWith("/") || normalized.startsWith("~/") || normalized.startsWith("//")) { - return false; - } - if (URI_SCHEME_RE.test(normalized)) return false; - if (/^[A-Za-z]:\//.test(normalized)) return false; - - const segments = normalized.split("/"); - if (!segments.length) return false; - return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); -}; - -const isRelativeFilePath = (value: string) => { - if (value === "." || value === "..") return false; - - const normalized = value.replace(/\\/g, "/"); - const segments = normalized.split("/"); - const hasNonTraversalSegment = segments.some((segment) => segment && segment !== "." && segment !== ".."); - - if (normalized.startsWith("./") || normalized.startsWith("../")) { - return hasNonTraversalSegment; - } - - const [firstSegment, secondSegment] = normalized.split("/"); - if (!secondSegment || firstSegment.length <= 1) return false; - if (secondSegment === "." || secondSegment === "..") return false; - return firstSegment.startsWith(".") && SAFE_PATH_CHAR_RE.test(secondSegment); -}; - -const isBareRelativeFilePath = (value: string) => { - if (value.includes("/") || value.includes("\\") || value.includes(":")) return false; - if (!BARE_FILENAME_RE.test(value)) return false; - - const extension = value.split(".").pop() ?? ""; - if (!/[A-Za-z]/.test(extension)) return false; - - const dotCount = (value.match(/\./g) ?? []).length; - if (dotCount === 1 && !value.includes("_") && !value.includes("-")) { - const [name, tld] = value.split("."); - if (/^[A-Za-z]{2,24}$/.test(name ?? "") && /^[A-Za-z]{2,10}$/.test(tld ?? "")) { - return false; - } - } - - return true; -}; - -const LEADING_PUNCTU = /[\"'`\(\[\{<]/; -const TRAILING_PUNCTU = /[\"'`\)\]}>.,:;!?]/; - -const isLikelyWebLink = (value: string) => WEB_LINK_RE.test(value); - -const isLikelyFilePath = (value: string) => { - if (FILE_URI_RE.test(value)) return true; - if (WINDOWS_PATH_RE.test(value)) return true; - if (POSIX_PATH_RE.test(value)) return true; - if (TILDE_PATH_RE.test(value)) return true; - if (isRelativeFilePath(value)) return true; - if (isBareRelativeFilePath(value)) return true; - if (isWorkspaceRelativeFilePath(value)) return true; - - return false; -}; - -const parseLinkFromToken = ( - token: string, - options: LinkDetectionOptions = {}, -): { href: string; type: LinkType; value: string } | null => { - let start = 0; - let end = token.length; - - while (start < end && LEADING_PUNCTU.test(token[start] ?? "")) { - start += 1; - } - - while (end > start && TRAILING_PUNCTU.test(token[end - 1] ?? "")) { - end -= 1; - } - - const value = token.slice(start, end); - if (!value) return null; - - if (isLikelyWebLink(value)) { - return { - value, - type: "url", - href: value.toLowerCase().startsWith("www.") ? `https://${value}` : value, - }; - } - - if ((options.allowFilePaths ?? true) && isLikelyFilePath(value)) { - return { - value, - type: "file", - href: value, - }; - } - - return null; -}; - -const splitTextTokens = (text: string, options: LinkDetectionOptions = {}): TextSegment[] => { - const tokens: TextSegment[] = []; - const matches = text.matchAll(/\S+/g); - let position = 0; - - for (const match of matches) { - const token = match[0] ?? ""; - const index = match.index ?? 0; - - if (index > position) { - tokens.push({ kind: "text", value: text.slice(position, index) }); - } - - const link = parseLinkFromToken(token, options); - if (!link) { - tokens.push({ kind: "text", value: token }); - } else { - const start = token.indexOf(link.value); - if (start > 0) { - tokens.push({ kind: "text", value: token.slice(0, start) }); - } - tokens.push({ kind: "link", value: link.value, href: link.href, type: link.type }); - const end = start + link.value.length; - if (end < token.length) { - tokens.push({ kind: "text", value: token.slice(end) }); - } - } - - position = index + token.length; - } - - if (position < text.length) { - tokens.push({ kind: "text", value: text.slice(position) }); - } - - return tokens; -}; - -const escapeHtml = (value: string) => - value.replace(/&/g, "&").replace(//g, ">"); - -const renderInlineTextWithLinks = (text: string, options: LinkDetectionOptions = {}) => { - const tokens = splitTextTokens(text, options); - return tokens - .map((token) => { - if (token.kind === "text") return escapeHtml(token.value); - return `${escapeHtml(token.value)}`; - }) - .join(""); -}; - -const normalizeRelativePath = (relativePath: string, workspaceRoot: string) => { - const root = workspaceRoot.trim().replace(/\\/g, "/").replace(/\/+$/g, ""); - if (!root) return null; - - const relative = relativePath.trim().replace(/\\/g, "/"); - if (!relative) return null; - - const isPosixRoot = root.startsWith("/"); - const rootValue = isPosixRoot ? root.slice(1) : root; - const rootParts = rootValue.split("/").filter((value) => value.length > 0); - const isWindowsDrive = /^[A-Za-z]:$/.test(rootParts[0] ?? ""); - const resolved: string[] = [...rootParts]; - const segments = relative.split("/"); - - for (const segment of segments) { - if (!segment || segment === ".") continue; - - if (segment === "..") { - if (!(isWindowsDrive && resolved.length === 1)) { - resolved.pop(); - } - continue; - } - - resolved.push(segment); - } - - const normalized = resolved.join("/"); - if (isPosixRoot) return `/${normalized || ""}` || "/"; - return normalized; -}; - -const normalizeFilePath = (href: string, workspaceRoot: string): string | null => { - const strippedHref = stripFileReferenceSuffix(href); - if (!strippedHref) return null; - - if (FILE_URI_RE.test(href)) { - try { - const parsed = new URL(href); - if (parsed.protocol !== "file:") return null; - const raw = decodeURIComponent(parsed.pathname || ""); - if (!raw) return null; - if (/^\/[A-Za-z]:\//.test(raw)) { - return raw.slice(1); - } - if (parsed.hostname && !parsed.pathname.startsWith(`/${parsed.hostname}`) && !raw.startsWith("/")) { - return `/${parsed.hostname}${raw}`; - } - return raw; - } catch { - const raw = decodeURIComponent(href.replace(/^file:\/\//, "")); - if (!raw) return null; - return raw; - } - } - - const trimmed = strippedHref.trim(); - if (isRelativeFilePath(trimmed) || isBareRelativeFilePath(trimmed) || isWorkspaceRelativeFilePath(trimmed)) { - if (!workspaceRoot) return null; - return normalizeRelativePath(trimmed, workspaceRoot); - } - - return href; -}; - -function clampText(text: string, max = 800) { - if (text.length <= max) return text; - return `${text.slice(0, max)}\n\n… (truncated)`; -} - -const SEARCH_HIGHLIGHT_MARK_ATTR = "data-search-highlight"; - -const clearTextHighlights = (root: HTMLElement) => { - const marks = root.querySelectorAll(`mark[${SEARCH_HIGHLIGHT_MARK_ATTR}="true"]`); - marks.forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark); - }); - root.normalize(); -}; - -const applyTextHighlights = (root: HTMLElement, query: string) => { - clearTextHighlights(root); - const needle = query.trim().toLowerCase(); - if (!needle) return; - - const walker = document.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - const value = node.nodeValue ?? ""; - if (!value.trim()) return NodeFilter.FILTER_REJECT; - const parent = node.parentElement; - if (!parent) return NodeFilter.FILTER_REJECT; - if (parent.closest("pre, code")) return NodeFilter.FILTER_REJECT; - if (parent.tagName === "SCRIPT" || parent.tagName === "STYLE") return NodeFilter.FILTER_REJECT; - return value.toLowerCase().includes(needle) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - }, - }, - ); - - const nodes: Text[] = []; - let current = walker.nextNode(); - while (current) { - nodes.push(current as Text); - current = walker.nextNode(); - } - - nodes.forEach((node) => { - const text = node.nodeValue ?? ""; - const lower = text.toLowerCase(); - let searchIndex = 0; - const fragment = document.createDocumentFragment(); - - while (searchIndex < text.length) { - const matchIndex = lower.indexOf(needle, searchIndex); - if (matchIndex === -1) { - fragment.appendChild(document.createTextNode(text.slice(searchIndex))); - break; - } - - if (matchIndex > searchIndex) { - fragment.appendChild(document.createTextNode(text.slice(searchIndex, matchIndex))); - } - - const mark = document.createElement("mark"); - mark.setAttribute(SEARCH_HIGHLIGHT_MARK_ATTR, "true"); - mark.className = "rounded px-0.5 bg-amber-4/70 text-current"; - mark.textContent = text.slice(matchIndex, matchIndex + needle.length); - fragment.appendChild(mark); - searchIndex = matchIndex + needle.length; - } - - node.parentNode?.replaceChild(fragment, node); - }); -}; - -function useThrottledValue(value: () => T, delayMs: number | (() => number) = 80) { - const [state, setState] = createSignal(value()); - let timer: ReturnType | undefined; - let hasEmitted = false; - - createEffect(() => { - const next = value(); - const delay = typeof delayMs === "function" ? delayMs() : delayMs; - // Always apply the first non-empty value synchronously so the initial - // render never falls through to the raw-text fallback. - if (!delay || !hasEmitted) { - hasEmitted = true; - setState(() => next); - return; - } - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - setState(() => next); - timer = undefined; - }, delay); - }); - - onCleanup(() => { - if (timer) clearTimeout(timer); - }); - - return state; -} - -const MARKDOWN_CACHE_MAX_ENTRIES = 100; -const LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD = 12_000; -const LARGE_TEXT_PREVIEW_CHARS = 3_200; -const markdownHtmlCache = new Map(); -const expandedLargeTextPartIds = new Set(); -const rendererByTone = new Map<"light" | "dark", ReturnType>(); - -function markdownCacheKey(tone: "light" | "dark", text: string) { - return `${tone}\u0000${text}`; -} - -function readMarkdownCache(key: string) { - const cached = markdownHtmlCache.get(key); - if (cached === undefined) return; - markdownHtmlCache.delete(key); - markdownHtmlCache.set(key, cached); - return cached; -} - -function writeMarkdownCache(key: string, html: string) { - if (markdownHtmlCache.has(key)) { - markdownHtmlCache.delete(key); - } - markdownHtmlCache.set(key, html); - - while (markdownHtmlCache.size > MARKDOWN_CACHE_MAX_ENTRIES) { - const oldest = markdownHtmlCache.keys().next().value; - if (!oldest) break; - markdownHtmlCache.delete(oldest); - } -} - -function rendererForTone(tone: "light" | "dark") { - const cached = rendererByTone.get(tone); - if (cached) return cached; - const next = createCustomRenderer(tone); - rendererByTone.set(tone, next); - return next; -} - -function createCustomRenderer(tone: "light" | "dark") { - const renderer = new marked.Renderer(); - const codeBlockClass = - tone === "dark" - ? "bg-gray-12/10 border-gray-11/20 text-gray-12" - : "bg-gray-1/80 border-gray-6/70 text-gray-12"; - const inlineCodeClass = - tone === "dark" - ? "bg-gray-12/15 text-gray-12" - : "bg-gray-2/70 text-gray-12"; - - const isSafeUrl = (url: string) => { - const normalized = (url || "").trim().toLowerCase(); - if (normalized.startsWith("javascript:")) return false; - // Allow data:image/* URIs (base64-encoded images from AI models) but block - // other data: schemes (e.g. data:text/html) which could be used for XSS. - if (normalized.startsWith("data:")) return normalized.startsWith("data:image/"); - return true; - }; - - renderer.html = ({ text }) => escapeHtml(text); - - renderer.code = ({ text, lang }) => { - const language = lang || ""; - return ` -
- ${ - language - ? `
${escapeHtml(language)}
` - : "" - } -
${escapeHtml(
-          text
-        )}
-
- `; - }; - - renderer.codespan = ({ text }) => { - return `${escapeHtml( - text - )}`; - }; - - renderer.link = ({ href, title, text }) => { - const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "#") : "#"; - const safeTitle = title ? escapeHtml(title) : ""; - return ` - - ${text} - - `; - }; - - renderer.image = ({ href, title, text }) => { - const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "") : ""; - const safeTitle = title ? escapeHtml(title) : ""; - return ` - ${escapeHtml(text || - `; - }; - - return renderer; -} - -export default function PartView(props: Props) { - const platform = usePlatform(); - const p = () => props.part; - const developerMode = () => props.developerMode ?? false; - const tone = () => props.tone ?? "light"; - const showThinking = () => props.showThinking ?? true; - const renderMarkdown = () => props.renderMarkdown ?? false; - const markdownThrottleMs = () => Math.max(0, props.markdownThrottleMs ?? 100); - const textPartStableId = createMemo(() => { - if (p().type !== "text") return ""; - const record = p() as { id?: string | number; messageID?: string | number }; - const partId = record.id; - if (typeof partId === "string") return partId; - if (typeof partId === "number") return String(partId); - const messageId = record.messageID; - if (typeof messageId === "string") return `msg:${messageId}`; - if (typeof messageId === "number") return `msg:${String(messageId)}`; - return ""; - }); - const isPersistedExpanded = () => { - const id = textPartStableId(); - return Boolean(id && expandedLargeTextPartIds.has(id)); - }; - const [expandedLongText, setExpandedLongText] = createSignal(isPersistedExpanded()); - createEffect(() => { - if (!isPersistedExpanded()) return; - if (expandedLongText()) return; - setExpandedLongText(true); - }); - const rawText = createMemo(() => { - if (p().type !== "text") return ""; - return "text" in p() ? String((p() as { text: string }).text ?? "") : ""; - }); - const shouldCollapseLongText = createMemo( - () => renderMarkdown() && p().type === "text" && rawText().length >= LARGE_TEXT_COLLAPSE_CHAR_THRESHOLD, - ); - const collapsedLongText = createMemo( - () => shouldCollapseLongText() && !(expandedLongText() || isPersistedExpanded()), - ); - const collapsedPreviewText = createMemo(() => { - const text = rawText(); - if (!collapsedLongText()) return text; - if (text.length <= LARGE_TEXT_PREVIEW_CHARS) return text; - return `${text.slice(0, LARGE_TEXT_PREVIEW_CHARS)}\n\n...`; - }); - let textContainerEl: HTMLDivElement | undefined; - const fileInfo = () => { - if (p().type !== "file") return null; - const part = p() as { - filename?: string; - url?: string; - mime?: string; - source?: { - type?: string; - path?: string; - name?: string; - clientName?: string; - uri?: string; - }; - }; - const source = part.source ?? {}; - const sourceType = typeof source.type === "string" ? source.type : ""; - const sourcePath = typeof source.path === "string" ? source.path : ""; - const sourceName = typeof source.name === "string" ? source.name : ""; - const sourceClient = typeof source.clientName === "string" ? source.clientName : ""; - const sourceUri = typeof source.uri === "string" ? source.uri : ""; - const filename = typeof part.filename === "string" ? part.filename : ""; - const url = typeof part.url === "string" ? part.url : ""; - const pathName = sourcePath ? sourcePath.split(/[\\/]/).pop() ?? sourcePath : ""; - const title = filename || pathName || sourceName || url || "File"; - const detail = (() => { - if (sourceType === "symbol") { - if (sourcePath) return `${sourceName || "symbol"} - ${sourcePath}`; - return sourceName || ""; - } - if (sourceType === "resource") { - const details = [sourceClient, sourceUri].filter(Boolean).join(" - "); - return details || url; - } - return sourcePath || url; - })(); - const mime = typeof part.mime === "string" ? part.mime : ""; - return { title, detail, mime }; - }; - - const textClass = () => (tone() === "dark" ? "text-gray-12" : "text-gray-12"); - const subtleTextClass = () => (tone() === "dark" ? "text-gray-12/70" : "text-gray-11"); - const panelBgClass = () => (tone() === "dark" ? "bg-gray-2/10" : "bg-gray-2/30"); - const toolOnly = () => true; - const showToolOutput = () => developerMode(); - const markdownSource = createMemo(() => { - if (!renderMarkdown() || p().type !== "text") return ""; - if (collapsedLongText()) return ""; - return rawText(); - }); - const throttledMarkdownSource = useThrottledValue(markdownSource, markdownThrottleMs); - const renderedMarkdown = createMemo(() => { - if (!renderMarkdown() || p().type !== "text") return null; - if (collapsedLongText()) return null; - const text = throttledMarkdownSource(); - if (!text.trim()) return ""; - - const toneKey = tone(); - const cacheKey = markdownCacheKey(toneKey, text); - const cachedHtml = readMarkdownCache(cacheKey); - if (cachedHtml !== undefined) return cachedHtml; - - try { - const startedAt = perfNow(); - const renderer = rendererForTone(toneKey); - const result = marked.parse(text, { - breaks: true, - gfm: true, - renderer, - async: false - }); - const parseMs = Math.round((perfNow() - startedAt) * 100) / 100; - if (developerMode() && (parseMs >= 12 || text.length >= 6_000)) { - const record = p() as { id?: string; messageID?: string }; - recordPerfLog(true, "session.render", "markdown-parse", { - partID: record.id ?? null, - messageID: record.messageID ?? null, - chars: text.length, - ms: parseMs, - }); - } - - const html = typeof result === "string" ? result : ""; - // If marked returned empty HTML for non-empty source, treat as a parse - // failure so the fallback renders plain text instead of blank space. - if (!html && text.trim()) { - return null; - } - writeMarkdownCache(cacheKey, html); - return html; - } catch (error) { - console.error('Markdown parsing error:', error); - return null; - } - }); - - const openLink = async (href: string, type: LinkType) => { - if (type === "url") { - platform.openLink(href); - return; - } - - const filePath = normalizeFilePath(href, props.workspaceRoot ?? ""); - if (!filePath) return; - - if (!isTauriRuntime()) { - platform.openLink(href.startsWith("file://") ? href : `file://${filePath}`); - return; - } - - try { - const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); - await revealItemInDir(filePath).catch(() => openPath(filePath)); - } catch { - platform.openLink(href.startsWith("file://") ? href : `file://${filePath}`); - } - }; - - const openMarkdownLink = async (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - const anchor = target.closest("a"); - if (!(anchor instanceof HTMLAnchorElement)) return; - - const href = anchor.getAttribute("href")?.trim(); - if (!href) return; - const link = parseLinkFromToken(href); - if (!link) return; - - event.preventDefault(); - event.stopPropagation(); - await openLink(link.href, link.type); - }; - - const renderTextWithLinks = () => { - const text = "text" in p() ? String((p() as { text: string }).text) : ""; - if (!text) return {""}; - - const tokens = splitTextTokens(text); - return ( - - - {(token) => - token.kind === "link" ? ( - { - event.preventDefault(); - event.stopPropagation(); - void openLink(token.href, token.type); - }} - > - {token.value} - - ) : ( - token.value - ) - } - - - ); - }; - - createEffect(() => { - if (p().type !== "text") return; - const root = textContainerEl; - if (!root) return; - const query = props.highlightQuery ?? ""; - const markdownSnapshot = renderMarkdown() ? renderedMarkdown() : null; - queueMicrotask(() => { - if (!textContainerEl || textContainerEl !== root) return; - applyTextHighlights(textContainerEl, query); - }); - void markdownSnapshot; - }); - - const toolData = () => { - if (p().type !== "tool") return null; - return p() as any; - }; - - let toolSummaryRuns = 0; - let lastToolSummaryAt = 0; - const toolSummary = createMemo(() => { - if (p().type !== "tool") return null; - const startedAt = perfNow(); - const summary = summarizeStep(p()); - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - toolSummaryRuns += 1; - const now = Date.now(); - const sinceLastMs = lastToolSummaryAt > 0 ? now - lastToolSummaryAt : null; - lastToolSummaryAt = now; - - if (developerMode() && (elapsedMs >= 4 || (toolSummaryRuns >= 6 && (sinceLastMs ?? 0) < 300))) { - const record = p() as { id?: string; messageID?: string }; - recordPerfLog(true, "session.render", "tool-summary", { - partID: record.id ?? null, - messageID: record.messageID ?? null, - runs: toolSummaryRuns, - sinceLastMs, - ms: elapsedMs, - }); - } - - return summary; - }); - const toolState = () => toolData()?.state ?? {}; - const toolName = () => (toolData()?.tool ? String(toolData()?.tool) : "tool"); - const toolTitle = () => { - const title = toolSummary()?.title; - if (title) return title; - return toolState()?.title ? String(toolState().title) : toolName(); - }; - const toolStatus = () => (toolState()?.status ? String(toolState().status) : "unknown"); - const toolSubtitle = () => { - const detail = toolSummary()?.detail; - if (detail) return detail; - if (toolState()?.subtitle || toolState()?.detail || toolState()?.summary) { - return String(toolState().subtitle ?? toolState().detail ?? toolState().summary); - } - return ""; - }; - - const extractDiff = () => { - const state = toolState(); - const candidates = [state?.diff, state?.patch, state?.output]; - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - if (candidate.includes("@@") || candidate.includes("+++ ") || candidate.includes("--- ")) { - return candidate; - } - } - return null; - }; - - const diffText = createMemo(() => (p().type === "tool" ? extractDiff() : null)); - const normalizeToolText = (value: unknown) => { - if (typeof value !== "string") return ""; - return value.replace(/(?:\r?\n\s*)+$/, ""); - }; - const diffTextNormalized = createMemo(() => normalizeToolText(diffText())); - const diffLines = createMemo(() => (diffTextNormalized() ? diffTextNormalized().split("\n") : [])); - const diffLineClass = (line: string) => { - if (line.startsWith("+")) return "text-green-11 bg-green-1/40"; - if (line.startsWith("-")) return "text-red-11 bg-red-1/40"; - if (line.startsWith("@@")) return "text-blue-11 bg-blue-1/30"; - return "text-gray-12"; - }; - - const toolOutput = () => normalizeToolText(toolState()?.output); - const hasReadXmlOutput = createMemo(() => { - if (toolName().toLowerCase() !== "read") return false; - const output = toolOutput().trimStart(); - return output.startsWith("") || output.startsWith("") || output.startsWith(""); - }); - - const toolError = () => { - const error = toolState()?.error; - return typeof error === "string" ? error : null; - }; - - const toolInput = () => toolState()?.input; - - const diagnostics = () => { - const items = toolState()?.diagnostics; - return Array.isArray(items) ? items : []; - }; - - const formatDiagnosticLocation = (diagnostic: any) => { - const raw = diagnostic?.file ?? diagnostic?.path ?? diagnostic?.uri ?? ""; - const file = typeof raw === "string" ? raw.replace(/^file:\/\//, "") : ""; - const line = diagnostic?.line ?? diagnostic?.range?.start?.line; - const character = diagnostic?.character ?? diagnostic?.range?.start?.character; - const location = - typeof line === "number" - ? `${line + 1}${typeof character === "number" ? `:${character + 1}` : ""}` - : ""; - return `${file}${file && location ? ":" : ""}${location}`.trim(); - }; - - const formatDiagnosticLabel = (diagnostic: any) => { - const severity = diagnostic?.severity ?? diagnostic?.level; - if (typeof severity === "string") return severity; - if (severity === 1) return "error"; - if (severity === 2) return "warning"; - if (severity === 3) return "info"; - if (severity === 4) return "hint"; - return "diagnostic"; - }; - - const isLargeOutput = createMemo(() => toolOutput().length > 800); - - const [expandedOutput, setExpandedOutput] = createSignal(false); - const outputPreview = createMemo(() => { - const output = toolOutput(); - if (!output) return ""; - if (isLargeOutput() && !expandedOutput()) { - return `${output.slice(0, 800)}\n\n… (truncated)`; - } - return output; - }); - - const toolImages = () => { - const state = toolState(); - const candidates = Array.isArray(state?.images) ? state.images : []; - return candidates - .map((item: any) => { - if (typeof item === "string") return { src: item, alt: "" }; - const src = item?.url ?? item?.src ?? item?.data; - if (!src) return null; - if (item?.data && item?.mediaType && !String(item.data).startsWith("data:")) { - return { src: `data:${item.mediaType};base64,${item.data}`, alt: item?.alt ?? "" }; - } - return { src, alt: item?.alt ?? "" }; - }) - .filter(Boolean); - }; - - const inlineImage = () => { - if (p().type !== "file") return null; - const record = p() as any; - const mime = typeof record?.mime === "string" ? record.mime : ""; - if (!mime.startsWith("image/")) return null; - const src = record?.url ?? record?.src ?? record?.data ?? record?.source; - if (!src) return null; - if (record?.data && record?.mediaType && !String(record.data).startsWith("data:")) { - return `data:${record.mediaType};base64,${record.data}`; - } - return src as string; - }; - - return ( - - - -
-
{ - textContainerEl = el; - }} - class={`whitespace-pre-wrap break-words text-[14px] leading-relaxed max-h-[22rem] overflow-hidden ${textClass()}`.trim()} - > - {collapsedPreviewText()} -
- -
-
- { - textContainerEl = el; - }} - class={`whitespace-pre-wrap break-words ${textClass()}`.trim()} - > - {renderTextWithLinks()} -
- } - > - {/* null = parse error → plain text; "" = empty/pending → nothing; string = rendered HTML */} - -
{ textContainerEl = el; }} - class={`whitespace-pre-wrap break-words ${textClass()}`.trim()} - > - {renderTextWithLinks()} -
-
- -
{ textContainerEl = el; }} - class={`markdown-content max-w-none ${textClass()} - [&_strong]:font-semibold - [&_em]:italic - [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-4 - [&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3 - [&_h3]:text-lg [&_h3]:font-bold [&_h3]:my-2 - [&_p]:my-3 [&_p]:leading-relaxed - [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3 - [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3 - [&_li]:my-1 - [&_blockquote]:border-l-4 [&_blockquote]:border-dls-border [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic - [&_table]:w-full [&_table]:border-collapse [&_table]:my-4 - [&_th]:border [&_th]:border-dls-border [&_th]:p-2 [&_th]:bg-dls-hover - [&_td]:border [&_td]:border-dls-border [&_td]:p-2 - `.trim()} - innerHTML={renderedMarkdown()!} - onClick={openMarkdownLink} - /> - - - - - - - {(info) => ( -
-
- -
-
-
{info().title}
- -
{info().detail}
-
-
- -
- {info().mime} -
-
-
- )} -
-
- - - -
- Thinking -
-              {clampText(String((p() as { text: string }).text), 2000)}
-            
-
-
-
- - - -
-
-
-
- {toolTitle()} -
-
{toolName()}
-
-
- {toolStatus()} -
-
- - -
{toolSubtitle()}
-
- - 0}> -
-
Diagnostics
-
- - {(diag: any) => ( -
-
-
{String(diag?.message ?? "")}
- -
- {[diag?.source, diag?.code].filter(Boolean).join(" · ")} -
-
-
-
-
{formatDiagnosticLabel(diag)}
- -
{formatDiagnosticLocation(diag)}
-
-
-
- )} -
-
-
-
- - -
-
Diff
-
- - {(line) => ( -
- {line || " "} -
- )} -
-
-
-
- - 0}> -
- - {(image: any) => ( - {image.alt - )} - -
-
- - -
- {toolError()} -
-
- - -
-                {outputPreview()}
-              
-
- - -
- Raw read output -
-                  {outputPreview()}
-                
-
-
- - - - - - -
- Input -
-                  {safeStringify(toolInput())}
-                
-
-
-
-
-
- - - - - - -
- {p().type === "step-start" ? "Step started" : "Step finished"} - - - {" "}· {String((p() as any).reason)} - - -
-
- - - -
-            {safeStringify(p())}
-          
-
-
- - ); -} diff --git a/apps/app/src/app/components/question-modal.tsx b/apps/app/src/app/components/question-modal.tsx deleted file mode 100644 index e77b0642..00000000 --- a/apps/app/src/app/components/question-modal.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; -import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"; - -import { Check, ChevronRight, HelpCircle } from "lucide-solid"; - -import Button from "./button"; -import { t } from "../../i18n"; - -export type QuestionModalProps = { - open: boolean; - questions: QuestionInfo[]; - busy: boolean; - onReply: (answers: string[][]) => void; -}; - -export default function QuestionModal(props: QuestionModalProps) { - const [currentIndex, setCurrentIndex] = createSignal(0); - const [answers, setAnswers] = createSignal([]); - const [currentSelection, setCurrentSelection] = createSignal([]); - const [customInput, setCustomInput] = createSignal(""); - const [focusedOptionIndex, setFocusedOptionIndex] = createSignal(0); - - createEffect(() => { - if (props.open) { - setCurrentIndex(0); - setAnswers(new Array(props.questions.length).fill([])); - setCurrentSelection([]); - setCustomInput(""); - setFocusedOptionIndex(0); - } - }); - - const currentQuestion = createMemo(() => props.questions[currentIndex()]); - const isLastQuestion = createMemo(() => currentIndex() === props.questions.length - 1); - const canProceed = createMemo(() => { - const q = currentQuestion(); - if (!q) return false; - if (q.custom && customInput().trim().length > 0) return true; - return currentSelection().length > 0; - }); - - const handleNext = () => { - if (!canProceed()) return; - - const q = currentQuestion(); - if (!q) return; - - let answer: string[] = [...currentSelection()]; - if (q.custom && customInput().trim()) { - answer.push(customInput().trim()); - } - - const newAnswers = [...answers()]; - newAnswers[currentIndex()] = answer; - setAnswers(newAnswers); - - if (isLastQuestion()) { - props.onReply(newAnswers); - } else { - setCurrentIndex((i) => i + 1); - setCurrentSelection([]); - setCustomInput(""); - setFocusedOptionIndex(0); - } - }; - - const toggleOption = (option: string) => { - const q = currentQuestion(); - if (!q) return; - - if (q.multiple) { - setCurrentSelection((prev) => - prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] - ); - } else { - setCurrentSelection([option]); - if (!q.custom) { - setTimeout(() => { - const newAnswers = [...answers()]; - newAnswers[currentIndex()] = [option]; - setAnswers(newAnswers); - - if (isLastQuestion()) { - props.onReply(newAnswers); - } else { - setCurrentIndex((i) => i + 1); - setCurrentSelection([]); - setCustomInput(""); - setFocusedOptionIndex(0); - } - }, 150); - } - } - }; - - createEffect(() => { - if (!props.open) return; - - const handleKeyDown = (e: KeyboardEvent) => { - const q = currentQuestion(); - if (!q) return; - - const optionsCount = q.options.length; - - if (e.key === "ArrowDown") { - e.preventDefault(); - setFocusedOptionIndex((prev) => (prev + 1) % optionsCount); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setFocusedOptionIndex((prev) => (prev - 1 + optionsCount) % optionsCount); - } else if (e.key === "Enter") { - if (e.isComposing || e.keyCode === 229) return; - e.preventDefault(); - if (q.custom && document.activeElement?.tagName === "INPUT") { - handleNext(); - return; - } - - const option = q.options[focusedOptionIndex()]?.description; - if (option) { - toggleOption(option); - } - } - }; - - window.addEventListener("keydown", handleKeyDown); - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); - }); - - return ( - -
-
-
-
-
- -
-
-

- {currentQuestion()!.header || t("common.question")} -

-
- {t("question_modal.question_counter", undefined, { current: currentIndex() + 1, total: props.questions.length })} -
-
-
-

- {currentQuestion()!.question} -

-
- -
-
- - {(opt, idx) => { - const isSelected = () => currentSelection().includes(opt.description); - const isFocused = () => focusedOptionIndex() === idx(); - - return ( - - ); - }} - -
- - -
- - setCustomInput(e.currentTarget.value)} - class="w-full px-4 py-3 rounded-xl bg-dls-surface border border-dls-border focus:border-dls-accent focus:ring-4 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:outline-none text-sm text-dls-text placeholder:text-dls-secondary transition-shadow" - placeholder={t("question_modal.custom_answer_placeholder")} - onKeyDown={(e) => { - if (e.key === "Enter") { - if (e.isComposing || e.keyCode === 229) return; - e.stopPropagation(); - handleNext(); - } - }} - /> -
-
-
- -
-
- ↑↓ - {t("common.navigate")} - - {t("common.select")} -
- -
- - - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/reload-workspace-toast.tsx b/apps/app/src/app/components/reload-workspace-toast.tsx deleted file mode 100644 index 99bcf958..00000000 --- a/apps/app/src/app/components/reload-workspace-toast.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Show } from "solid-js"; -import { AlertTriangle, RefreshCcw, X } from "lucide-solid"; - -import Button from "./button"; -import type { ReloadTrigger } from "../types"; - -export type ReloadWorkspaceToastProps = { - open: boolean; - title: string; - description: string; - trigger?: ReloadTrigger | null; - warning?: string; - blockedReason?: string | null; - error?: string | null; - reloadLabel: string; - dismissLabel: string; - busy?: boolean; - canReload: boolean; - hasActiveRuns: boolean; - onReload: () => void; - onDismiss: () => void; -}; - -export default function ReloadWorkspaceToast(props: ReloadWorkspaceToastProps) { - const getDescription = () => { - if (!props.trigger) return props.description; - const { type, name, action } = props.trigger; - const trimmedName = name?.trim(); - const verb = - action === "removed" - ? "was removed" - : action === "added" - ? "was added" - : action === "updated" - ? "was updated" - : "changed"; - - if (type === "skill") { - return trimmedName - ? `Skill '${trimmedName}' ${verb}. Reload to use it.` - : "Skills changed. Reload to apply."; - } - - if (type === "plugin") { - return trimmedName - ? `Plugin '${trimmedName}' ${verb}. Reload to activate.` - : "Plugins changed. Reload to apply."; - } - - if (type === "mcp") { - return trimmedName - ? `MCP '${trimmedName}' ${verb}. Reload to connect.` - : "MCP config changed. Reload to apply."; - } - - if (type === "config") { - return trimmedName - ? `Config '${trimmedName}' ${verb}. Reload to apply.` - : "Config changed. Reload to apply."; - } - - if (type === "agent") { - return trimmedName - ? `Agent '${trimmedName}' ${verb}. Reload to use it.` - : "Agents changed. Reload to apply."; - } - - if (type === "command") { - return trimmedName - ? `Command '${trimmedName}' ${verb}. Reload to use it.` - : "Commands changed. Reload to apply."; - } - - return "Config changed. Reload to apply."; - }; - - return ( - -
-
-
- -
- -
-
-
-
- {props.title} - - - Active tasks - - -
- - -
-
- {props.hasActiveRuns ? ( - Reloading will stop active tasks. - ) : props.error ? ( - {props.error} - ) : ( - getDescription() - )} -
- -
- - {props.warning} -
-
- -
Blocked: {props.blockedReason}
-
-
-
-
- - -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/rename-session-modal.tsx b/apps/app/src/app/components/rename-session-modal.tsx deleted file mode 100644 index 9bdcde38..00000000 --- a/apps/app/src/app/components/rename-session-modal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Show, createEffect } from "solid-js"; -import { X } from "lucide-solid"; -import { t, currentLocale } from "../../i18n"; - -import Button from "./button"; -import TextInput from "./text-input"; - -export type RenameSessionModalProps = { - open: boolean; - title: string; - busy: boolean; - canSave: boolean; - onClose: () => void; - onSave: () => void; - onTitleChange: (value: string) => void; -}; - -export default function RenameSessionModal(props: RenameSessionModalProps) { - let inputRef: HTMLInputElement | undefined; - const translate = (key: string) => t(key, currentLocale()); - - createEffect(() => { - if (props.open) { - requestAnimationFrame(() => { - inputRef?.focus(); - if (inputRef) { - inputRef.select(); - } - }); - } - }); - - return ( - -
-
-
-
-
-

{translate("session.rename_title")}

-

{translate("session.rename_description")}

-
- -
- -
- props.onTitleChange(e.currentTarget.value)} - placeholder={translate("session.rename_placeholder")} - class="bg-gray-3" - onKeyDown={(event) => { - if (event.key !== "Enter" || event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - if (props.canSave) props.onSave(); - }} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/rename-workspace-modal.tsx b/apps/app/src/app/components/rename-workspace-modal.tsx deleted file mode 100644 index 3e44e846..00000000 --- a/apps/app/src/app/components/rename-workspace-modal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Show, createEffect } from "solid-js"; -import { X } from "lucide-solid"; -import { t, currentLocale } from "../../i18n"; - -import Button from "./button"; -import TextInput from "./text-input"; - -export type RenameWorkspaceModalProps = { - open: boolean; - title: string; - busy: boolean; - canSave: boolean; - onClose: () => void; - onSave: () => void; - onTitleChange: (value: string) => void; -}; - -export default function RenameWorkspaceModal(props: RenameWorkspaceModalProps) { - let inputRef: HTMLInputElement | undefined; - const translate = (key: string) => t(key, currentLocale()); - - createEffect(() => { - if (props.open) { - requestAnimationFrame(() => { - inputRef?.focus(); - if (inputRef) { - inputRef.select(); - } - }); - } - }); - - return ( - -
-
-
-
-
-

{translate("workspace.rename_title")}

-

{translate("workspace.rename_description")}

-
- -
- -
- props.onTitleChange(e.currentTarget.value)} - placeholder={translate("workspace.rename_placeholder")} - class="bg-gray-3" - onKeyDown={(event) => { - if (event.key !== "Enter" || event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - if (props.canSave) props.onSave(); - }} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/reset-modal.tsx b/apps/app/src/app/components/reset-modal.tsx deleted file mode 100644 index 8dbf05f0..00000000 --- a/apps/app/src/app/components/reset-modal.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Match, Show, Switch } from "solid-js"; - -import { X } from "lucide-solid"; -import { t, type Language } from "../../i18n"; - -import Button from "./button"; -import TextInput from "./text-input"; - -const RESET_CONFIRM_PLACEHOLDER = "{resetWord}"; -const RESET_CONFIRM_WORD = "RESET"; - -export type ResetModalProps = { - open: boolean; - mode: "onboarding" | "all"; - text: string; - busy: boolean; - canReset: boolean; - hasActiveRuns: boolean; - language: Language; - onClose: () => void; - onConfirm: () => void; - onTextChange: (value: string) => void; -}; - -export default function ResetModal(props: ResetModalProps) { - const translate = (key: string) => t(key, props.language); - const resetConfirmationHint = () => { - const template = translate("settings.reset_confirmation_hint"); - const parts = template.split(RESET_CONFIRM_PLACEHOLDER); - - if (parts.length === 1) return template; - - return parts.flatMap((part, index) => - index < parts.length - 1 - ? [part, {RESET_CONFIRM_WORD}] - : [part], - ); - }; - - return ( - -
-
-
-
-
-

- - {translate("settings.reset_onboarding_title")} - {translate("settings.reset_app_data_title")} - -

-

{resetConfirmationHint()}

-
- -
- -
-
- - - {translate("settings.reset_onboarding_warning")} - - {translate("settings.reset_app_data_warning")} - -
- - -
{translate("settings.reset_stop_active_runs")}
-
- - props.onTextChange(e.currentTarget.value)} - disabled={props.busy} - /> -
- -
- - -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/restriction-notice-modal.tsx b/apps/app/src/app/components/restriction-notice-modal.tsx deleted file mode 100644 index 59d8218d..00000000 --- a/apps/app/src/app/components/restriction-notice-modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Show } from "solid-js"; -import { X } from "lucide-solid"; - -import { currentLocale, t } from "../../i18n"; -import Button from "./button"; - -export type RestrictionNoticeModalProps = { - open: boolean; - title: string; - message: string; - onClose: () => void; -}; - -export default function RestrictionNoticeModal(props: RestrictionNoticeModalProps) { - return ( - -
-
-
-
-

{props.title}

-
- -
- -
-

{props.message}

-
- -
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/select-menu.tsx b/apps/app/src/app/components/select-menu.tsx deleted file mode 100644 index 7e9e45ed..00000000 --- a/apps/app/src/app/components/select-menu.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { Check, ChevronDown } from "lucide-solid"; - -export type SelectMenuOption = { - value: string; - label: string; -}; - -type SelectMenuProps = { - options: SelectMenuOption[]; - value: string; - onChange: (value: string) => void; - disabled?: boolean; - placeholder?: string; - id?: string; - /** For pairing with a visible `
- - -
-
-
props.setAgentPickerRef(el)} - > - - - -
-
- {t("composer.agent_label")} -
- -
- event.preventDefault() - } - > - - {t( - "composer.loading_agents", - )} -
- } - > - - - - - {(agent: Agent) => { - const active = () => - props.selectedAgent === - agent.name; - return ( - - ); - }} - - - - -
- {props.agentPickerError} -
-
- -
-
- -
- - - - 0} - > -
(variantPickerRef = el)} - > - - -
-
- {t("composer.behavior_label")} -
-
- - {(option) => ( - - )} - -
-
-
-
-
-
- - - - ); -} diff --git a/apps/app/src/app/components/session/message-list.tsx b/apps/app/src/app/components/session/message-list.tsx deleted file mode 100644 index 0644c97c..00000000 --- a/apps/app/src/app/components/session/message-list.tsx +++ /dev/null @@ -1,1223 +0,0 @@ -import { - For, - Show, - createEffect, - createMemo, - createSignal, - onCleanup, -} from "solid-js"; -import type { JSX } from "solid-js"; -import type { Part, Session } from "@opencode-ai/sdk/v2/client"; -import { - Check, - ChevronDown, - ChevronRight, - CircleAlert, - Copy, - File, -} from "lucide-solid"; -import { createVirtualizer } from "@tanstack/solid-virtual"; - -import { - SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX, - type MessageGroup, - type MessageWithParts, - type StepGroupMode, -} from "../../types"; -import { - groupMessageParts, - isUserVisiblePart, - summarizeStep, -} from "../../utils"; -import PartView from "../part-view"; -import { perfNow, recordPerfLog } from "../../lib/perf-log"; -import { t } from "../../../i18n"; - -export type MessageListProps = { - messages: MessageWithParts[]; - isStreaming?: boolean; - developerMode: boolean; - showThinking: boolean; - getSessionById?: (sessionId: string | null) => Session | null; - getMessagesBySessionId?: (sessionId: string | null) => MessageWithParts[]; - ensureSessionLoaded?: (sessionId: string) => Promise | void; - sessionLoadingById?: (sessionId: string | null) => boolean; - expandedStepIds: Set; - setExpandedStepIds: (updater: (current: Set) => Set) => void; - openSessionById?: (sessionId: string) => void; - searchMatchMessageIds?: ReadonlySet; - activeSearchMessageId?: string | null; - searchHighlightQuery?: string; - workspaceRoot?: string; - scrollElement?: () => HTMLElement | undefined; - setScrollToMessageById?: ( - handler: ((messageId: string, behavior?: ScrollBehavior) => boolean) | null, - ) => void; - footer?: JSX.Element; - variant?: "default" | "nested"; -}; - -type StepClusterBlock = { - kind: "steps-cluster"; - id: string; - stepGroups: StepTimelineGroup[]; - messageIds: string[]; - isUser: boolean; -}; - -type StepTimelineGroup = { - id: string; - parts: Part[]; - mode: StepGroupMode; -}; - -type MessageBlock = { - kind: "message"; - message: MessageWithParts; - renderableParts: Part[]; - attachments: Array<{ - url: string; - filename: string; - mime: string; - }>; - groups: MessageGroup[]; - isUser: boolean; - messageId: string; -}; - -type MessageBlockItem = MessageBlock | StepClusterBlock; - -const VIRTUALIZATION_THRESHOLD = 500; -const VIRTUAL_OVERSCAN = 4; - -function normalizePath(path: string) { - const normalized = path.replace(/\\/g, "/").trim().replace(/\/+/g, "/"); - if (!normalized || normalized === "/") return normalized; - return normalized.replace(/\/+$/, ""); -} - - -type TaskStepInfo = { - isTask: boolean; - agentType?: string; - sessionId?: string; - description?: string; -}; - -function formatAgentType(agentType: string): string { - const clean = agentType.trim().replace(/[_-]+/g, " "); - if (!clean) return ""; - return clean - .split(/\s+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} - -function getTaskStepInfo(part: Part): TaskStepInfo { - if (part.type !== "tool") return { isTask: false }; - - const record = part as any; - const tool = typeof record.tool === "string" ? record.tool.toLowerCase() : ""; - if (tool !== "task") return { isTask: false }; - - const state = record.state ?? {}; - const input = - state.input && typeof state.input === "object" - ? (state.input as Record) - : {}; - const metadata = - state.metadata && typeof state.metadata === "object" - ? (state.metadata as Record) - : {}; - - const rawAgentType = - typeof input.subagent_type === "string" ? input.subagent_type.trim() : ""; - const agentType = rawAgentType ? formatAgentType(rawAgentType) : undefined; - const rawSessionId = - metadata.sessionId ?? - metadata.sessionID ?? - state.sessionId ?? - state.sessionID; - const rawDescription = - typeof input.description === "string" && input.description.trim() - ? input.description.trim() - : undefined; - const sessionId = - typeof rawSessionId === "string" && rawSessionId.trim() - ? rawSessionId.trim() - : undefined; - - return { isTask: true, agentType, sessionId, description: rawDescription }; -} - -function compactPathToken(value: string) { - const token = value.trim().replace(/^[`'"([{]+|[`'"\])},.;:]+$/g, ""); - const segments = token.split(/[\\/]/).filter(Boolean); - return segments.length > 0 ? segments[segments.length - 1] : token; -} - -function compactText(value: string, max = 42) { - const singleLine = value.replace(/\s+/g, " ").trim(); - if (!singleLine) return ""; - return singleLine.length > max - ? `${singleLine.slice(0, Math.max(0, max - 3))}...` - : singleLine; -} - -function cleanReasoningPreview(value: string) { - return value - .replace(/\[REDACTED\]/g, "") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/__([^_]+)__/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .replace(/\s+\n/g, "\n") - .trim(); -} - -function formatStructuredValue(value: unknown) { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value.trim(); - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function hasStructuredValue(value: unknown) { - if (value === undefined || value === null) return false; - if (typeof value === "string") return value.trim().length > 0; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "object") return Object.keys(value as Record).length > 0; - return true; -} - -function isPathLike(value: string) { - return ( - /^(?:[A-Za-z]:[\\/]|~[\\/]|\/[\w_\-~]|\.\.?[\\/])/.test(value) || - /[\\/](?:\.opencode|Users|Library|workspaces)[\\/]/.test(value) - ); -} - -function toolHeadline(part: Part) { - if (part.type !== "tool") return ""; - - const record = part as any; - const state = record.state ?? {}; - const input = - state.input && typeof state.input === "object" - ? (state.input as Record) - : {}; - const tool = typeof record.tool === "string" ? record.tool.toLowerCase() : ""; - - const pick = (...keys: string[]) => { - for (const key of keys) { - const value = input[key]; - if (typeof value === "string" && value.trim()) return value.trim(); - } - return ""; - }; - - const target = (...keys: string[]) => { - const raw = pick(...keys); - if (!raw) return ""; - return isPathLike(raw) ? compactPathToken(raw) : raw; - }; - - if (tool === "bash") { - const description = pick("description"); - if (description) return compactText(description); - const command = pick("command", "cmd"); - return command ? compactText(t("message_list.tool_run_command", undefined, { command }), 48) : t("message_list.tool_run_command_fallback"); - } - - if (tool === "read") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_reviewed_file", undefined, { file }) : t("message_list.tool_reviewed_file_fallback"); - } - - if (tool === "edit") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_updated_file", undefined, { file }) : t("message_list.tool_updated_file_fallback"); - } - - if (tool === "write" || tool === "apply_patch") { - const file = target("filePath", "path", "file"); - return file ? t("message_list.tool_update_file", undefined, { file }) : t("message_list.tool_update_file_fallback"); - } - - if (tool === "grep" || tool === "glob" || tool === "search") { - const pattern = pick("pattern", "query"); - return pattern ? t("message_list.tool_searched_pattern", undefined, { pattern: compactText(pattern, 36) }) : t("message_list.tool_searched_code_fallback"); - } - - if (tool === "list" || tool === "list_files") { - const path = target("path"); - return path ? t("message_list.tool_reviewed_path", undefined, { path }) : t("message_list.tool_reviewed_files_fallback"); - } - - if (tool === "task") { - const description = pick("description"); - if (description) return compactText(description); - const agent = pick("subagent_type"); - return agent ? t("message_list.tool_delegate_agent", undefined, { agent }) : t("message_list.tool_delegate_task_fallback"); - } - - if (tool === "todowrite") { - return t("message_list.tool_update_todo"); - } - - if (tool === "todoread") { - return t("message_list.tool_read_todo"); - } - - if (tool === "webfetch") { - const url = pick("url"); - return url ? t("message_list.tool_checked_url", undefined, { url: compactText(url, 36) }) : t("message_list.tool_checked_web_fallback"); - } - - if (tool === "skill") { - const name = pick("name"); - return name ? t("message_list.tool_load_skill_named", undefined, { name }) : t("message_list.tool_load_skill_fallback"); - } - - const fallback = tool - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return fallback ? compactText(fallback, 40) : ""; -} - -export default function MessageList(props: MessageListProps) { - const [copyingId, setCopyingId] = createSignal(null); - let previousMessagePartCountById = new Map(); - let previousMessageBlockById = new Map(); - let previousBlockRenderKey = ""; - let copyTimeout: number | undefined; - const isNestedVariant = () => props.variant === "nested"; - const isAttachmentPart = (part: Part) => { - if (part.type !== "file") return false; - const url = (part as { url?: string }).url; - return typeof url === "string" && !url.startsWith("file://"); - }; - const attachmentsForParts = (parts: Part[]) => - parts - .filter(isAttachmentPart) - .map((part) => { - const record = part as { - url?: string; - filename?: string; - mime?: string; - }; - return { - url: record.url ?? "", - filename: record.filename ?? "attachment", - mime: record.mime ?? "application/octet-stream", - }; - }) - .filter((attachment) => !!attachment.url); - const isImageAttachment = (mime: string) => mime.startsWith("image/"); - onCleanup(() => { - if (copyTimeout !== undefined) { - window.clearTimeout(copyTimeout); - } - }); - - const handleCopy = async (text: string, id: string) => { - try { - await navigator.clipboard.writeText(text); - setCopyingId(id); - if (copyTimeout !== undefined) { - window.clearTimeout(copyTimeout); - } - copyTimeout = window.setTimeout(() => { - setCopyingId(null); - copyTimeout = undefined; - }, 2000); - } catch { - // ignore - } - }; - - const partToText = (part: Part) => { - if (part.type === "text") { - return String((part as { text?: string }).text ?? ""); - } - if (part.type === "agent") { - const name = (part as { name?: string }).name ?? ""; - return name ? `@${name}` : "@agent"; - } - if (part.type === "file") { - const record = part as { - label?: string; - path?: string; - filename?: string; - }; - const label = record.label ?? record.path ?? record.filename ?? ""; - return label ? `@${label}` : "@file"; - } - return ""; - }; - - const toggleSteps = (id: string, relatedIds: string[] = []) => { - props.setExpandedStepIds((current) => { - const next = new Set(current); - const isExpanded = - next.has(id) || relatedIds.some((relatedId) => next.has(relatedId)); - if (isExpanded) { - next.delete(id); - relatedIds.forEach((relatedId) => next.delete(relatedId)); - } else { - next.add(id); - relatedIds.forEach((relatedId) => next.add(relatedId)); - } - return next; - }); - }; - - const isStepsExpanded = (id: string, relatedIds: string[] = []) => - props.expandedStepIds.has(id) || - relatedIds.some((relatedId) => props.expandedStepIds.has(relatedId)); - - const renderablePartsForMessage = (message: MessageWithParts) => - message.parts.filter((part) => { - if (!props.developerMode && !isUserVisiblePart(part)) { - return false; - } - - if (part.type === "reasoning") { - return props.showThinking; - } - - if (part.type === "step-start" || part.type === "step-finish") { - return false; - } - - if ( - part.type === "text" || - part.type === "tool" || - part.type === "agent" || - part.type === "file" - ) { - return true; - } - - return props.developerMode; - }); - - const messageBlocks = createMemo(() => { - const startedAt = perfNow(); - const renderKey = `${props.developerMode ? 1 : 0}:${props.showThinking ? 1 : 0}`; - const blocks: MessageBlockItem[] = []; - const nextMessagePartCountById = new Map(); - const nextMessageBlockById = new Map(); - let changedMessageCount = 0; - let addedMessageCount = 0; - let toolPartCount = 0; - let stepGroupCount = 0; - - props.messages.forEach((message, index) => { - const renderableParts = renderablePartsForMessage(message); - if (!renderableParts.length) return; - - const messageId = String((message.info as any).id ?? ""); - const idKey = messageId || `idx:${index}`; - const totalParts = message.parts.length; - nextMessagePartCountById.set(idKey, totalParts); - const previousPartCount = previousMessagePartCountById.get(idKey); - const previousBlock = previousMessageBlockById.get(idKey); - if (previousPartCount === undefined) { - addedMessageCount += 1; - } else if (previousPartCount !== totalParts) { - changedMessageCount += 1; - } - - const isUser = (message.info as any).role === "user"; - const canReuseStableBlock = - previousBlockRenderKey === renderKey && - index < props.messages.length - 1 && - previousPartCount !== undefined && - previousPartCount === totalParts && - previousBlock?.kind === "message" && - previousBlock.isUser === isUser; - - if (canReuseStableBlock && previousBlock) { - toolPartCount += previousBlock.renderableParts.reduce( - (count, part) => (part.type === "tool" ? count + 1 : count), - 0, - ); - stepGroupCount += previousBlock.groups.reduce( - (count, group) => (group.kind === "steps" ? count + 1 : count), - 0, - ); - blocks.push(previousBlock); - nextMessageBlockById.set(idKey, previousBlock); - return; - } - - toolPartCount += renderableParts.reduce( - (count, part) => (part.type === "tool" ? count + 1 : count), - 0, - ); - const groupId = String((message.info as any).id ?? "message"); - const attachments = attachmentsForParts(renderableParts); - const nonAttachmentParts = renderableParts.filter((part) => !isAttachmentPart(part)); - const groups = groupMessageParts(nonAttachmentParts, groupId); - const isStepsOnly = - groups.length > 0 && groups.every((group) => group.kind === "steps"); - const stepGroups = isStepsOnly - ? (groups as { - kind: "steps"; - id: string; - parts: Part[]; - segment: "execution"; - mode: StepGroupMode; - }[]) - : []; - stepGroupCount += groups.reduce( - (count, group) => (group.kind === "steps" ? count + 1 : count), - 0, - ); - - if (isStepsOnly) { - blocks.push({ - kind: "steps-cluster", - id: stepGroups[0].id, - stepGroups: stepGroups.map((group) => ({ - id: group.id, - parts: group.parts, - mode: group.mode, - })), - messageIds: [messageId], - isUser, - }); - return; - } - - const block: MessageBlock = { - kind: "message", - message, - renderableParts, - attachments, - groups, - isUser, - messageId, - }; - blocks.push(block); - nextMessageBlockById.set(idKey, block); - }); - - let removedMessageCount = 0; - previousMessagePartCountById.forEach((_partCount, id) => { - if (!nextMessagePartCountById.has(id)) { - removedMessageCount += 1; - } - }); - previousMessagePartCountById = nextMessagePartCountById; - previousMessageBlockById = nextMessageBlockById; - previousBlockRenderKey = renderKey; - - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - if ( - props.developerMode && - (elapsedMs >= 6 || - (Boolean(props.isStreaming) && - props.messages.length >= 16 && - changedMessageCount <= 2 && - addedMessageCount <= 1 && - removedMessageCount === 0) || - (Boolean(props.isStreaming) && toolPartCount >= 10)) - ) { - recordPerfLog(true, "session.render", "message-blocks", { - messageCount: props.messages.length, - blockCount: blocks.length, - changedMessageCount, - addedMessageCount, - removedMessageCount, - toolPartCount, - stepGroupCount, - streaming: Boolean(props.isStreaming), - ms: elapsedMs, - }); - } - - return blocks; - }); - - const latestAssistantMessageId = createMemo(() => { - for (let index = props.messages.length - 1; index >= 0; index -= 1) { - const message = props.messages[index]; - if ((message.info as any).role === "assistant") { - return String((message.info as any).id ?? ""); - } - } - return ""; - }); - - const blockIndexByMessageId = createMemo(() => { - const next = new Map(); - messageBlocks().forEach((block, index) => { - if (block.kind === "steps-cluster") { - block.messageIds.forEach((id) => { - if (id) next.set(id, index); - }); - return; - } - if (block.messageId) { - next.set(block.messageId, index); - } - }); - return next; - }); - - const shouldVirtualize = createMemo( - () => - Boolean(props.scrollElement?.()) && - messageBlocks().length >= VIRTUALIZATION_THRESHOLD, - ); - - const virtualizer = createVirtualizer({ - get count() { - return messageBlocks().length; - }, - getScrollElement: () => props.scrollElement?.() ?? null, - estimateSize: () => 220, - overscan: VIRTUAL_OVERSCAN, - getItemKey: (index) => { - const block = messageBlocks()[index]; - if (!block) return `block-${index}`; - if (block.kind === "steps-cluster") { - return `steps-${block.messageIds.join(",")}`; - } - return `message-${block.messageId}`; - }, - }); - - let cachedVirtualRows: ReturnType = []; - const virtualRows = createMemo(() => { - if (!shouldVirtualize()) { - cachedVirtualRows = []; - return []; - } - const rows = virtualizer.getVirtualItems(); - if (rows.length > 0) { - cachedVirtualRows = rows; - return rows; - } - return cachedVirtualRows; - }); - - const virtualRowByIndex = createMemo(() => { - const map = new Map< - number, - ReturnType[number] - >(); - virtualRows().forEach((row) => { - map.set(row.index, row); - }); - return map; - }); - - const virtualRowIndices = createMemo(() => - virtualRows().map((row) => row.index), - ); - - const shouldUseContentVisibility = createMemo( - () => !shouldVirtualize() && messageBlocks().length > 500, - ); - const blockPerfStyle = (index: number): JSX.CSSProperties | undefined => { - if (!shouldUseContentVisibility()) return undefined; - const total = messageBlocks().length; - if (index >= total - 24) return undefined; - return { - "content-visibility": "auto", - "contain-intrinsic-size": "220px", - }; - }; - - createEffect(() => { - const setScrollToMessageById = props.setScrollToMessageById; - if (!setScrollToMessageById) return; - const indexById = blockIndexByMessageId(); - const useVirtualization = shouldVirtualize(); - - setScrollToMessageById((messageId, behavior = "smooth") => { - const index = indexById.get(messageId); - if (index === undefined) return false; - - if (useVirtualization) { - virtualizer.scrollToIndex(index, { align: "center" }); - return true; - } - - const container = props.scrollElement?.(); - if (!container) return false; - const escapedId = messageId.replace(/"/g, '\\"'); - const target = container.querySelector( - `[data-message-id="${escapedId}"]`, - ) as HTMLElement | null; - if (!target) return false; - target.scrollIntoView({ behavior, block: "center" }); - return true; - }); - }); - - createEffect(() => { - if (!shouldVirtualize()) return; - queueMicrotask(() => { - virtualizer.measure(); - }); - }); - - onCleanup(() => { - props.setScrollToMessageById?.(null); - }); - - const sessionStreamState = (messages: MessageWithParts[]) => { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const info = messages[index]?.info as { role?: string; time?: { completed?: number } }; - if (info?.role !== "assistant") continue; - return !info.time?.completed; - } - return false; - }; - - const SubagentThread = (threadProps: { part: Part }) => { - const task = createMemo(() => getTaskStepInfo(threadProps.part)); - const sessionId = createMemo(() => task().sessionId ?? null); - const [open, setOpen] = createSignal(true); - let requestedSessionId = ""; - const session = createMemo(() => props.getSessionById?.(sessionId()) ?? null); - const childMessages = createMemo(() => props.getMessagesBySessionId?.(sessionId()) ?? []); - const loading = createMemo(() => props.sessionLoadingById?.(sessionId()) ?? false); - const streaming = createMemo(() => loading() || sessionStreamState(childMessages())); - const label = createMemo(() => { - const title = session()?.title?.trim(); - if (title) return title; - if (task().description) return task().description!; - if (task().agentType) return t("message_list.subagent_type_task", undefined, { agentType: task().agentType! }); - return t("message_list.subagent_session_fallback"); - }); - const statusLabel = createMemo(() => { - if (loading()) return t("message_list.subagent_loading_transcript"); - if (streaming()) return t("message_list.subagent_running"); - if (childMessages().length > 0) { - const count = childMessages().length; - return t("message_list.subagent_message_count", undefined, { count, plural: count === 1 ? "" : "s" }); - } - return t("message_list.subagent_waiting_transcript"); - }); - - createEffect(() => { - const id = sessionId(); - if (!id) return; - if (!props.ensureSessionLoaded) return; - if (requestedSessionId === id) return; - requestedSessionId = id; - void props.ensureSessionLoaded(id); - }); - - return ( - -
-
- - {statusLabel()} - - {task().agentType} - - - - -
- -
- 0} - fallback={
{t("message.waiting_subagent")}
} - > - -
-
-
-
-
- ); - }; - - /** Transcript step row */ - const StepRow = (rowProps: { - id: string; - part: Part; - isUser: boolean; - groupMode?: StepGroupMode; - }) => { - const summary = createMemo(() => summarizeStep(rowProps.part)); - const task = createMemo(() => getTaskStepInfo(rowProps.part)); - const toolState = createMemo(() => { - if (rowProps.part.type !== "tool") return {} as Record; - return (((rowProps.part as any).state ?? {}) as Record); - }); - const toolInput = createMemo(() => { - const input = toolState().input; - return input && typeof input === "object" - ? (input as Record) - : undefined; - }); - const toolOutput = createMemo(() => toolState().output); - const expandable = createMemo( - () => - rowProps.part.type === "tool" && - (hasStructuredValue(toolInput()) || - hasStructuredValue(toolOutput()) || - Boolean(task().isTask && task().sessionId)), - ); - const expanded = createMemo( - () => expandable() && isStepsExpanded(rowProps.id), - ); - const headline = createMemo(() => { - const title = summary().title?.trim() ?? ""; - if (title) return title; - const fromTool = toolHeadline(rowProps.part); - return fromTool || t("message_list.step_updates_progress"); - }); - const reasoningText = createMemo(() => { - if (rowProps.part.type !== "reasoning") return ""; - const raw = typeof (rowProps.part as any).text === "string" ? (rowProps.part as any).text : ""; - return cleanReasoningPreview(raw); - }); - - if (rowProps.part.type === "reasoning") { - return ( -
-
{reasoningText() || headline()}
-
- ); - } - - return ( -
- - -
- -
-
{t("message.tool_request_label")}
-
{formatStructuredValue(toolInput())}
-
-
- -
-
{t("message.tool_result_label")}
-
{formatStructuredValue(toolOutput())}
-
-
- - - -
-
-
- ); - }; - - /** Quiet steps list */ - const StepsList = (listProps: { - groupId: string; - parts: Part[]; - isUser: boolean; - groupMode: StepGroupMode; - }) => ( -
- - {(part, index) => ( - - )} - -
- ); - - /** Expandable steps container */ - const StepsContainer = (containerProps: { - id: string; - relatedIds?: string[]; - stepGroups: StepTimelineGroup[]; - isUser: boolean; - isInline?: boolean; - }) => { - const useInnerTimelineScroll = () => !Boolean(props.isStreaming); - return ( -
-
-
-
- - {(group) => ( - - )} - -
-
-
-
- ); - }; - - const renderBlock = (block: MessageBlockItem, blockIndex: number) => { - const blockMessageIds = - block.kind === "steps-cluster" ? block.messageIds : [block.messageId]; - const hasSearchMatch = blockMessageIds.some((id) => - props.searchMatchMessageIds?.has(id), - ); - const hasActiveSearchMatch = blockMessageIds.some( - (id) => id === props.activeSearchMessageId, - ); - const searchOutlineClass = hasActiveSearchMatch - ? "outline outline-2 outline-amber-8/70 outline-offset-2 rounded-2xl" - : hasSearchMatch - ? "outline outline-1 outline-amber-7/50 outline-offset-1 rounded-2xl" - : ""; - - if (block.kind === "steps-cluster") { - return ( -
-
- stepGroup.id) - .filter((stepId) => stepId !== block.id)} - stepGroups={block.stepGroups} - isUser={block.isUser} - /> -
-
- ); - } - - const groupSpacing = block.isUser ? "mb-3" : "mb-4"; - const isSyntheticSessionError = - !block.isUser && - block.messageId.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX); - - if (isSyntheticSessionError) { - const messageText = block.renderableParts - .map((part) => partToText(part)) - .join(" ") - .replace(/\s*\n+\s*/g, " ") - .replace(/\s{2,}/g, " ") - .trim(); - - return ( -
-
- -
-
- ); - } - - return ( -
-
- 0}> -
- - {(attachment) => ( -
- } - > -
- {attachment.filename} -
-
-
-
- {attachment.filename} -
-
- {attachment.mime} -
-
-
- )} -
-
-
- - {(group, idx) => ( -
- - {(() => { - const isStreamingLatestAssistant = - !block.isUser && - props.isStreaming && - block.messageId === latestAssistantMessageId(); - const markdownThrottleMs = isStreamingLatestAssistant - ? 120 - : 100; - return ( - - ); - })()} - - {group.kind === "steps" && - (() => { - const stepGroup = group as { - kind: "steps"; - id: string; - parts: Part[]; - segment: "execution"; - mode: StepGroupMode; - }; - return ( - - ); - })()} -
- )} -
- -
- -
-
-
-
- ); - }; - - return ( -
- - - {(block, blockIndex) => renderBlock(block, blockIndex())} - -
- } - > - 0} - fallback={ -
- - {(block, blockIndex) => renderBlock(block, blockIndex())} - -
- } - > -
- - {(rowIndex) => { - const virtualRow = virtualRowByIndex().get(rowIndex); - if (!virtualRow) return null; - const block = messageBlocks()[rowIndex]; - if (!block) return null; - return ( -
virtualizer.measureElement(el)} - class="absolute left-0 top-0 w-full pb-4" - style={{ - transform: `translateY(${virtualRow.start}px)`, - }} - > - {renderBlock(block, rowIndex)} -
- ); - }} -
-
-
-
- {props.footer} - - ); -} diff --git a/apps/app/src/app/components/session/scroll-controller.ts b/apps/app/src/app/components/session/scroll-controller.ts deleted file mode 100644 index dfab1f84..00000000 --- a/apps/app/src/app/components/session/scroll-controller.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { - createEffect, - createMemo, - createSignal, - on, - onCleanup, - type Accessor, - type JSX, -} from "solid-js"; - -const FOLLOW_LATEST_BOTTOM_GAP_PX = 96; -const SCROLL_GESTURE_WINDOW_MS = 250; - -type SessionScrollMode = "follow-latest" | "manual-browse"; - -type SessionScrollControllerOptions = { - selectedSessionId: Accessor; - renderedMessages: Accessor; - containerRef: Accessor; - contentRef: Accessor; -}; - -export function createSessionScrollController( - options: SessionScrollControllerOptions, -) { - const [mode, setMode] = createSignal("follow-latest"); - const [topClippedMessageId, setTopClippedMessageId] = createSignal(null); - const isAtBottom = createMemo(() => mode() === "follow-latest"); - - let lastKnownScrollTop = 0; - let programmaticScroll = false; - let programmaticScrollResetRafA: number | undefined; - let programmaticScrollResetRafB: number | undefined; - let observedContentHeight = 0; - let lastGestureAt = 0; - - const hasScrollGesture = () => Date.now() - lastGestureAt < SCROLL_GESTURE_WINDOW_MS; - - const updateOverflowAnchor = () => { - const container = options.containerRef(); - if (!container) return; - container.style.overflowAnchor = mode() === "follow-latest" ? "none" : "auto"; - }; - - const markScrollGesture = (target?: EventTarget | null) => { - const container = options.containerRef(); - if (!container) return; - - const el = target instanceof Element ? target : undefined; - const nested = el?.closest("[data-scrollable]"); - if (nested && nested !== container) return; - - lastGestureAt = Date.now(); - }; - - const clearProgrammaticScrollReset = () => { - if (programmaticScrollResetRafA !== undefined) { - window.cancelAnimationFrame(programmaticScrollResetRafA); - programmaticScrollResetRafA = undefined; - } - if (programmaticScrollResetRafB !== undefined) { - window.cancelAnimationFrame(programmaticScrollResetRafB); - programmaticScrollResetRafB = undefined; - } - }; - - const releaseProgrammaticScrollSoon = () => { - clearProgrammaticScrollReset(); - programmaticScrollResetRafA = window.requestAnimationFrame(() => { - programmaticScrollResetRafA = undefined; - programmaticScrollResetRafB = window.requestAnimationFrame(() => { - programmaticScrollResetRafB = undefined; - programmaticScroll = false; - }); - }); - }; - - const scrollToBottom = (behavior: ScrollBehavior = "auto") => { - const container = options.containerRef(); - if (!container) return; - - setMode("follow-latest"); - setTopClippedMessageId(null); - programmaticScroll = true; - - if (behavior === "smooth") { - container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); - releaseProgrammaticScrollSoon(); - return; - } - - container.scrollTop = container.scrollHeight; - window.requestAnimationFrame(() => { - const next = options.containerRef(); - if (!next) { - programmaticScroll = false; - return; - } - next.scrollTop = next.scrollHeight; - releaseProgrammaticScrollSoon(); - }); - }; - - const refreshTopClippedMessage = () => { - const container = options.containerRef(); - if (!container) { - setTopClippedMessageId(null); - return; - } - - const containerRect = container.getBoundingClientRect(); - const messageEls = container.querySelectorAll("[data-message-id]"); - const latestMessageEl = messageEls[messageEls.length - 1] as HTMLElement | undefined; - const latestMessageId = latestMessageEl?.getAttribute("data-message-id")?.trim() ?? ""; - let nextId: string | null = null; - - for (const node of messageEls) { - const el = node as HTMLElement; - const rect = el.getBoundingClientRect(); - if (rect.bottom <= containerRect.top + 1) continue; - if (rect.top >= containerRect.bottom - 1) break; - - if (rect.top < containerRect.top - 1) { - const id = el.getAttribute("data-message-id")?.trim() ?? ""; - if (id) { - const isLatestMessage = id === latestMessageId; - const fillsViewportTail = rect.bottom >= containerRect.bottom - 1; - if (isLatestMessage || fillsViewportTail) { - nextId = id; - } - } - } - break; - } - - setTopClippedMessageId(nextId); - }; - - const handleScroll: JSX.EventHandlerUnion = (event) => { - const container = event.currentTarget as HTMLDivElement; - if (programmaticScroll) { - lastKnownScrollTop = container.scrollTop; - refreshTopClippedMessage(); - return; - } - - if (!hasScrollGesture()) { - lastKnownScrollTop = container.scrollTop; - refreshTopClippedMessage(); - return; - } - - const bottomGap = - container.scrollHeight - (container.scrollTop + container.clientHeight); - if (bottomGap <= FOLLOW_LATEST_BOTTOM_GAP_PX) { - setMode("follow-latest"); - } else if (container.scrollTop < lastKnownScrollTop - 1) { - setMode("manual-browse"); - } - lastKnownScrollTop = container.scrollTop; - refreshTopClippedMessage(); - }; - - const jumpToLatest = (behavior: ScrollBehavior = "smooth") => { - scrollToBottom(behavior); - }; - - const jumpToStartOfMessage = (behavior: ScrollBehavior = "smooth") => { - const messageId = topClippedMessageId(); - const container = options.containerRef(); - if (!messageId || !container) return; - - const escapedId = messageId.replace(/"/g, '\\"'); - const target = container.querySelector( - `[data-message-id="${escapedId}"]`, - ) as HTMLElement | null; - if (!target) return; - - setMode("manual-browse"); - target.scrollIntoView({ behavior, block: "start" }); - }; - - createEffect(() => { - mode(); - updateOverflowAnchor(); - }); - - createEffect(() => { - const content = options.contentRef(); - if (!content) return; - - observedContentHeight = content.offsetHeight; - const observer = new ResizeObserver(() => { - const nextContent = options.contentRef(); - if (!nextContent) return; - - const nextHeight = nextContent.offsetHeight; - const grew = nextHeight > observedContentHeight + 1; - observedContentHeight = nextHeight; - - if (grew && isAtBottom()) { - scrollToBottom("auto"); - return; - } - - refreshTopClippedMessage(); - }); - - observer.observe(content); - onCleanup(() => observer.disconnect()); - }); - - createEffect( - on( - options.selectedSessionId, - (sessionId, previousSessionId) => { - if (sessionId === previousSessionId) return; - if (!sessionId) return; - - setMode("follow-latest"); - setTopClippedMessageId(null); - observedContentHeight = 0; - queueMicrotask(() => scrollToBottom("auto")); - }, - ), - ); - - createEffect(() => { - options.renderedMessages(); - queueMicrotask(refreshTopClippedMessage); - }); - - onCleanup(() => { - clearProgrammaticScrollReset(); - }); - - return { - isAtBottom, - topClippedMessageId, - handleScroll, - markScrollGesture, - scrollToBottom, - jumpToLatest, - jumpToStartOfMessage, - }; -} diff --git a/apps/app/src/app/components/session/workspace-session-list.tsx b/apps/app/src/app/components/session/workspace-session-list.tsx deleted file mode 100644 index bc61cc10..00000000 --- a/apps/app/src/app/components/session/workspace-session-list.tsx +++ /dev/null @@ -1,871 +0,0 @@ -import { - For, - Show, - createEffect, - createSignal, - onCleanup, - onMount, -} from "solid-js"; -import { - ChevronDown, - ChevronRight, - Loader2, - MoreHorizontal, - Plus, -} from "lucide-solid"; - -import { getDisplaySessionTitle } from "../../lib/session-title"; -import type { WorkspaceInfo } from "../../lib/tauri"; -import type { - WorkspaceConnectionState, - WorkspaceSessionGroup, -} from "../../types"; -import { - formatRelativeTime, - getWorkspaceTaskLoadErrorDisplay, - isSandboxWorkspace, - isWindowsPlatform, -} from "../../utils"; -import { t } from "../../../i18n"; - -type Props = { - workspaceSessionGroups: WorkspaceSessionGroup[]; - showInitialLoading?: boolean; - selectedWorkspaceId: string; - developerMode: boolean; - selectedSessionId: string | null; - showSessionActions?: boolean; - sessionStatusById?: Record; - connectingWorkspaceId: string | null; - workspaceConnectionStateById: Record; - newTaskDisabled: boolean; - onSelectWorkspace: (workspaceId: string) => Promise | boolean | void; - onOpenSession: (workspaceId: string, sessionId: string) => void; - onPrefetchSession?: (workspaceId: string, sessionId: string) => void; - onCreateTaskInWorkspace: (workspaceId: string) => void; - onOpenRenameSession?: () => void; - onOpenDeleteSession?: () => void; - onOpenRenameWorkspace: (workspaceId: string) => void; - onShareWorkspace: (workspaceId: string) => void; - onRevealWorkspace: (workspaceId: string) => void; - onRecoverWorkspace: ( - workspaceId: string, - ) => Promise | boolean | void; - onTestWorkspaceConnection: ( - workspaceId: string, - ) => Promise | boolean | void; - onEditWorkspaceConnection: (workspaceId: string) => void; - onForgetWorkspace: (workspaceId: string) => void; - onOpenCreateWorkspace: () => void; -}; - -const MAX_SESSIONS_PREVIEW = 6; - -type SessionListItem = WorkspaceSessionGroup["sessions"][number]; -type FlattenedSessionRow = { session: SessionListItem; depth: number }; -type SessionTreeState = { - childrenByParent: Map; - ancestorIdsBySessionId: Map; - descendantCountBySessionId: Map; - activeIds: Set; -}; - -const normalizeSessionParentID = (session: SessionListItem) => { - const parentID = session.parentID?.trim(); - return parentID || ""; -}; - -const getRootSessions = (sessions: WorkspaceSessionGroup["sessions"]) => { - const byID = new Set(sessions.map((session) => session.id)); - return sessions.filter((session) => { - const parentID = normalizeSessionParentID(session); - return !parentID || !byID.has(parentID); - }); -}; - -const buildSessionTreeState = ( - sessions: WorkspaceSessionGroup["sessions"], - sessionStatusById: Record | undefined, -): SessionTreeState => { - const childrenByParent = new Map(); - const ancestorIdsBySessionId = new Map(); - const descendantCountBySessionId = new Map(); - const activeIds = new Set(); - const sessionIds = new Set(sessions.map((session) => session.id)); - - sessions.forEach((session) => { - const parentID = normalizeSessionParentID(session); - if (!parentID || !sessionIds.has(parentID)) return; - const siblings = childrenByParent.get(parentID) ?? []; - siblings.push(session); - childrenByParent.set(parentID, siblings); - }); - - const walk = (session: SessionListItem, ancestors: string[]) => { - ancestorIdsBySessionId.set(session.id, ancestors); - const children = childrenByParent.get(session.id) ?? []; - let descendantCount = 0; - let subtreeActive = (sessionStatusById?.[session.id] ?? "idle") !== "idle"; - - children.forEach((child) => { - const childState = walk(child, [...ancestors, session.id]); - descendantCount += 1 + childState.descendantCount; - subtreeActive = subtreeActive || childState.subtreeActive; - }); - - descendantCountBySessionId.set(session.id, descendantCount); - if (subtreeActive) activeIds.add(session.id); - return { descendantCount, subtreeActive }; - }; - - getRootSessions(sessions).forEach((session) => { - walk(session, []); - }); - - return { - childrenByParent, - ancestorIdsBySessionId, - descendantCountBySessionId, - activeIds, - }; -}; - -const flattenSessionRows = ( - sessions: WorkspaceSessionGroup["sessions"], - rootLimit: number, - tree: SessionTreeState, - expandedSessionIds: Set, - forcedExpandedSessionIds: Set, -) => { - const roots = getRootSessions(sessions).slice(0, rootLimit); - const rows: FlattenedSessionRow[] = []; - const visited = new Set(); - - const walk = (session: SessionListItem, depth: number) => { - if (visited.has(session.id)) return; - visited.add(session.id); - rows.push({ session, depth }); - const children = tree.childrenByParent.get(session.id) ?? []; - if (!children.length) return; - const expanded = - expandedSessionIds.has(session.id) || forcedExpandedSessionIds.has(session.id); - if (!expanded) return; - children.forEach((child) => walk(child, depth + 1)); - }; - - roots.forEach((root) => walk(root, 0)); - return rows; -}; - -const workspaceLabel = (workspace: WorkspaceInfo) => - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - workspace.path?.trim() || - t("workspace_list.workspace_fallback"); - -const workspaceKindLabel = (workspace: WorkspaceInfo) => - workspace.workspaceType === "remote" - ? isSandboxWorkspace(workspace) - ? t("workspace.sandbox_badge") - : t("workspace.remote_badge") - : t("workspace.local_badge"); - -const WORKSPACE_SWATCHES = ["#2563eb", "#5a67d8", "#f97316", "#10b981"]; - -const workspaceSwatchColor = (seed: string) => { - const value = seed.trim() || "workspace"; - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash << 5) - hash + value.charCodeAt(index); - hash |= 0; - } - return WORKSPACE_SWATCHES[Math.abs(hash) % WORKSPACE_SWATCHES.length]; -}; - -export default function WorkspaceSessionList(props: Props) { - const revealLabel = () => isWindowsPlatform() - ? t("workspace_list.reveal_explorer") - : t("workspace_list.reveal_finder"); - const [expandedWorkspaceIds, setExpandedWorkspaceIds] = createSignal< - Set - >(new Set()); - const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = - createSignal>({}); - const [workspaceMenuId, setWorkspaceMenuId] = createSignal( - null, - ); - const [sessionMenuOpen, setSessionMenuOpen] = createSignal(false); - const [expandedSessionIds, setExpandedSessionIds] = createSignal>( - new Set(), - ); - let workspaceMenuRef: HTMLDivElement | undefined; - let sessionMenuRef: HTMLDivElement | undefined; - - const isWorkspaceExpanded = (workspaceId: string) => - expandedWorkspaceIds().has(workspaceId); - - const expandWorkspace = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - setExpandedWorkspaceIds((prev) => { - if (prev.has(id)) return prev; - const next = new Set(prev); - next.add(id); - return next; - }); - }; - - const toggleWorkspaceExpanded = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - setExpandedWorkspaceIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }; - - onMount(() => { - expandWorkspace(props.selectedWorkspaceId); - }); - - createEffect(() => { - expandWorkspace(props.selectedWorkspaceId); - }); - - const previewCount = (workspaceId: string) => - previewCountByWorkspaceId()[workspaceId] ?? MAX_SESSIONS_PREVIEW; - - const previewSessions = ( - workspaceId: string, - sessions: WorkspaceSessionGroup["sessions"], - tree: SessionTreeState, - forcedExpandedSessionIds: Set, - ) => - flattenSessionRows( - sessions, - previewCount(workspaceId), - tree, - expandedSessionIds(), - forcedExpandedSessionIds, - ); - - const toggleSessionExpanded = (sessionId: string) => { - const id = sessionId.trim(); - if (!id) return; - setExpandedSessionIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }; - - const showMoreSessions = (workspaceId: string, totalRoots: number) => { - expandWorkspace(workspaceId); - setPreviewCountByWorkspaceId((current) => { - const next = { ...current }; - const existing = next[workspaceId] ?? MAX_SESSIONS_PREVIEW; - next[workspaceId] = Math.min(existing + MAX_SESSIONS_PREVIEW, totalRoots); - return next; - }); - }; - - const showMoreLabel = (workspaceId: string, totalRoots: number) => { - const remaining = Math.max(0, totalRoots - previewCount(workspaceId)); - const nextCount = Math.min(MAX_SESSIONS_PREVIEW, remaining); - return nextCount > 0 ? t("workspace_list.show_more", undefined, { count: nextCount }) : t("workspace_list.show_more_fallback"); - }; - - createEffect(() => { - if (!workspaceMenuId()) return; - const closeMenu = (event: PointerEvent) => { - if (!workspaceMenuRef) return; - const target = event.target as Node | null; - if (target && workspaceMenuRef.contains(target)) return; - setWorkspaceMenuId(null); - }; - window.addEventListener("pointerdown", closeMenu); - onCleanup(() => window.removeEventListener("pointerdown", closeMenu)); - }); - - createEffect(() => { - props.selectedSessionId; - setSessionMenuOpen(false); - }); - - createEffect(() => { - const workspaceId = props.selectedWorkspaceId.trim(); - if (!workspaceId) return; - - const group = props.workspaceSessionGroups.find( - (entry) => entry.workspace.id === workspaceId, - ); - if (!group?.sessions.length) return; - - const selectedId = props.selectedSessionId?.trim() ?? ""; - const selectedIndex = selectedId - ? group.sessions.findIndex((session) => session.id === selectedId) - : -1; - const start = selectedIndex >= 0 ? Math.max(0, selectedIndex - 2) : 0; - const end = selectedIndex >= 0 - ? Math.min(group.sessions.length, selectedIndex + 3) - : Math.min(group.sessions.length, 4); - - group.sessions.slice(start, end).forEach((session) => { - props.onPrefetchSession?.(workspaceId, session.id); - }); - }); - - createEffect(() => { - if (!sessionMenuOpen()) return; - const closeMenu = (event: PointerEvent) => { - if (!sessionMenuRef) return; - const target = event.target as Node | null; - if (target && sessionMenuRef.contains(target)) return; - setSessionMenuOpen(false); - }; - window.addEventListener("pointerdown", closeMenu); - onCleanup(() => window.removeEventListener("pointerdown", closeMenu)); - }); - - const renderSessionRow = ( - workspaceId: string, - row: FlattenedSessionRow, - tree: SessionTreeState, - forcedExpandedSessionIds: Set, - ) => { - const session = () => row.session; - const depth = () => row.depth; - const isSelected = () => props.selectedSessionId === session().id; - const displayTitle = () => - getDisplaySessionTitle(session().title); - const hasChildren = () => - (tree.descendantCountBySessionId.get(session().id) ?? 0) > 0; - const hiddenChildCount = () => - tree.descendantCountBySessionId.get(session().id) ?? 0; - const isExpanded = () => - expandedSessionIds().has(session().id) || - forcedExpandedSessionIds.has(session().id); - const isSessionActive = () => tree.activeIds.has(session().id); - const canManageSession = () => - Boolean( - props.showSessionActions && - isSelected() && - (props.onOpenRenameSession || props.onOpenDeleteSession), - ); - - const openSession = () => { - setSessionMenuOpen(false); - props.onOpenSession(workspaceId, session().id); - }; - - const prefetchSession = () => { - if (workspaceId !== props.selectedWorkspaceId) return; - props.onPrefetchSession?.(workspaceId, session().id); - }; - - return ( -
-
{ - if (event.key !== "Enter" && event.key !== " ") return; - if (event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - openSession(); - }} - > -
- 0}> - - - } - > - - - - - - - {displayTitle()} - -
- -
- - - -
-
- - -
(sessionMenuRef = el)} - class="absolute right-0 top-[calc(100%+6px)] z-20 w-48 rounded-[18px] border border-dls-border bg-dls-surface p-1.5 shadow-[var(--dls-shell-shadow)]" - onClick={(event) => event.stopPropagation()} - > - - - - - - - -
-
-
- ); - }; - - return ( -
-
-
- - {(group) => { - const tree = buildSessionTreeState( - group.sessions, - props.sessionStatusById, - ); - const forcedExpandedSessionIds = new Set( - props.selectedSessionId - ? tree.ancestorIdsBySessionId.get(props.selectedSessionId) ?? [] - : [], - ); - const workspace = () => group.workspace; - const isConnecting = () => - props.connectingWorkspaceId === workspace().id; - const connectionState = () => - props.workspaceConnectionStateById[workspace().id] ?? { - status: "idle", - message: null, - }; - const isConnectionActionBusy = () => - isConnecting() || connectionState().status === "connecting"; - const canRecover = () => - workspace().workspaceType === "remote" && - connectionState().status === "error"; - const isMenuOpen = () => workspaceMenuId() === workspace().id; - const taskLoadError = () => - getWorkspaceTaskLoadErrorDisplay(workspace(), group.error); - const statusLabel = () => { - if (group.status === "error") return taskLoadError().label; - if (isConnectionActionBusy()) return t("workspace_list.connecting"); - if (!props.developerMode) return ""; - if (props.selectedWorkspaceId === workspace().id) return t("workspace.selected"); - return workspaceKindLabel(workspace()); - }; - const statusTone = () => { - if (group.status === "error") { - return taskLoadError().tone === "offline" - ? "text-amber-11" - : "text-red-11"; - } - return "text-gray-9"; - }; - - return ( -
-
-
{ - expandWorkspace(workspace().id); - void Promise.resolve( - props.onSelectWorkspace(workspace().id), - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - expandWorkspace(workspace().id); - void Promise.resolve( - props.onSelectWorkspace(workspace().id), - ); - }} - > -
-
-
-
- {workspaceLabel(workspace())} -
- -
- {statusLabel()} -
-
-
-
- -
- - - - - - - -
-
- - -
(workspaceMenuRef = el)} - class="absolute right-0 top-[calc(100%+6px)] z-20 w-48 rounded-[18px] border border-dls-border bg-dls-surface p-1.5 shadow-[var(--dls-shell-shadow)]" - onClick={(event) => event.stopPropagation()} - > - - - - - - - - - - - - - -
-
-
- - -
-
- 0} - fallback={ - -
- {taskLoadError().message} -
-
- } - > - - {(row) => - renderSessionRow( - workspace().id, - row, - tree, - forcedExpandedSessionIds, - )} - - - - - - - - previewCount(workspace().id) - } - > - - -
- } - > -
- {t("workspace.loading_tasks")} -
- - } - > -
- - {(idx) => ( -
-
-
- )} - -
- -
-
- -
- ); - }} - -
-
- -
- -
-
- ); -} diff --git a/apps/app/src/app/components/status-bar.tsx b/apps/app/src/app/components/status-bar.tsx deleted file mode 100644 index fca3ded5..00000000 --- a/apps/app/src/app/components/status-bar.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Show, createMemo } from "solid-js"; -import { BookOpen, MessageCircle, Settings } from "lucide-solid"; - -import { t } from "../../i18n"; -import { useConnections } from "../connections/provider"; -import { usePlatform } from "../context/platform"; -import type { OpenworkServerStatus } from "../lib/openwork-server"; - -const DOCS_URL = "https://openworklabs.com/docs"; - -type StatusBarProps = { - clientConnected: boolean; - openworkServerStatus: OpenworkServerStatus; - developerMode: boolean; - settingsOpen: boolean; - onSendFeedback: () => void; - onOpenSettings: () => void; - providerConnectedIds: string[]; - statusLabel?: string; - statusDetail?: string; - statusDotClass?: string; - statusPingClass?: string; - statusPulse?: boolean; - showSettingsButton?: boolean; -}; - -export default function StatusBar(props: StatusBarProps) { - const connections = useConnections(); - const platform = usePlatform(); - const providerConnectedCount = createMemo(() => props.providerConnectedIds?.length ?? 0); - const mcpConnectedCount = createMemo( - () => Object.values(connections.mcpStatuses() ?? {}).filter((status) => status?.status === "connected").length, - ); - - const statusCopy = createMemo(() => { - if (props.statusLabel) { - return { - label: props.statusLabel, - detail: props.statusDetail ?? "", - dotClass: props.statusDotClass ?? "bg-green-9", - pingClass: props.statusPingClass ?? "bg-green-9/45 animate-ping", - pulse: props.statusPulse ?? true, - }; - } - - const providers = providerConnectedCount(); - const mcp = mcpConnectedCount(); - - if (props.clientConnected) { - const detailBits: string[] = []; - if (providers > 0) { - detailBits.push(t("status.providers_connected", undefined, { count: providers, plural: providers === 1 ? "" : "s" })); - } - if (mcp > 0) { - detailBits.push(t("status.mcp_connected", undefined, { count: mcp })); - } - if (!detailBits.length) { - detailBits.push(t("status.ready_for_tasks")); - } - if (props.developerMode) { - detailBits.push(t("status.developer_mode")); - } - return { - label: t("status.openwork_ready"), - detail: detailBits.join(" · "), - dotClass: "bg-green-9", - pingClass: "bg-green-9/45 animate-ping", - pulse: true, - }; - } - - if (props.openworkServerStatus === "limited") { - return { - label: t("status.limited_mode"), - detail: - mcp > 0 - ? t("status.limited_mcp_hint", undefined, { count: mcp }) - : t("status.limited_hint"), - dotClass: "bg-amber-9", - pingClass: "bg-amber-9/35", - pulse: false, - }; - } - - return { - label: t("status.disconnected_label"), - detail: t("status.disconnected_hint"), - dotClass: "bg-red-9", - pingClass: "bg-red-9/35", - pulse: false, - }; - }); - - return ( -
-
-
- - - - - - - {statusCopy().label} - {statusCopy().detail} -
- -
- - - - - -
-
-
- ); -} diff --git a/apps/app/src/app/components/status-toast.tsx b/apps/app/src/app/components/status-toast.tsx deleted file mode 100644 index c422d6f0..00000000 --- a/apps/app/src/app/components/status-toast.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Show } from "solid-js"; - -import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from "lucide-solid"; - -import Button from "./button"; - -export type StatusToastProps = { - open: boolean; - title: string; - description?: string | null; - tone?: "success" | "info" | "warning" | "error"; - actionLabel?: string; - onAction?: () => void; - dismissLabel?: string; - onDismiss: () => void; -}; - -export default function StatusToast(props: StatusToastProps) { - const tone = () => props.tone ?? "info"; - - return ( - -
-
-
- - ) : tone() === "error" ? ( - - ) : ( - - ) - } - > - - -
- -
-
-
-
{props.title}
- -

{props.description}

-
-
- - -
- - -
- - -
-
-
-
-
-
- ); -} diff --git a/apps/app/src/app/components/text-input.tsx b/apps/app/src/app/components/text-input.tsx deleted file mode 100644 index ad278ae1..00000000 --- a/apps/app/src/app/components/text-input.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { splitProps, JSX } from "solid-js"; - -type TextInputProps = JSX.InputHTMLAttributes & { - label?: string; - hint?: string; -}; - -export default function TextInput(props: TextInputProps) { - const [local, rest] = splitProps(props, ["label", "hint", "class", "ref"]); - - return ( - - ); -} diff --git a/apps/app/src/app/components/web-unavailable-surface.tsx b/apps/app/src/app/components/web-unavailable-surface.tsx deleted file mode 100644 index c21fc162..00000000 --- a/apps/app/src/app/components/web-unavailable-surface.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Show, createEffect } from "solid-js"; -import { ArrowUpRight } from "lucide-solid"; -import type { JSX } from "solid-js"; - -type WebUnavailableSurfaceProps = { - unavailable: boolean; - children: JSX.Element; - compact?: boolean; - class?: string; - contentClass?: string; -}; - -const MESSAGE = - "This feature is currently unavailable in OpenWork Web, check OpenWork Desktop for full functionality."; - -export default function WebUnavailableSurface(props: WebUnavailableSurfaceProps) { - let contentRef: HTMLDivElement | undefined; - - createEffect(() => { - if (!contentRef) return; - if (props.unavailable) { - contentRef.setAttribute("inert", ""); - contentRef.setAttribute("aria-disabled", "true"); - return; - } - contentRef.removeAttribute("inert"); - contentRef.removeAttribute("aria-disabled"); - }); - - return ( -
- - - - -
-
- {props.children} -
- - -
- ); -} diff --git a/apps/app/src/app/connections/mcp-view.tsx b/apps/app/src/app/connections/mcp-view.tsx deleted file mode 100644 index 7d30b76e..00000000 --- a/apps/app/src/app/connections/mcp-view.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import PresentationalMcpView from "../pages/mcp"; - -import { useConnections } from "./provider"; - -export type ConnectionsMcpViewProps = { - busy: boolean; - selectedWorkspaceRoot: string; - isRemoteWorkspace: boolean; - showHeader?: boolean; -}; - -export default function ConnectionsMcpView(props: ConnectionsMcpViewProps) { - const connections = useConnections(); - - return ( - - ); -} diff --git a/apps/app/src/app/connections/openwork-server-provider.tsx b/apps/app/src/app/connections/openwork-server-provider.tsx deleted file mode 100644 index b536f2df..00000000 --- a/apps/app/src/app/connections/openwork-server-provider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, type ParentProps } from "solid-js"; - -import type { OpenworkServerStore } from "./openwork-server-store"; - -const OpenworkServerContext = createContext(); - -export function OpenworkServerProvider(props: ParentProps<{ store: OpenworkServerStore }>) { - return ( - - {props.children} - - ); -} diff --git a/apps/app/src/app/connections/openwork-server-store.ts b/apps/app/src/app/connections/openwork-server-store.ts deleted file mode 100644 index 539d113f..00000000 --- a/apps/app/src/app/connections/openwork-server-store.ts +++ /dev/null @@ -1,673 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; - -import { t, currentLocale } from "../../i18n"; -import type { StartupPreference, WorkspaceDisplay } from "../types"; -import { isTauriRuntime } from "../utils"; -import { - openworkServerInfo, - openworkServerRestart, - opencodeRouterInfo, - orchestratorStatus, - type OpenCodeRouterInfo, - type OpenworkServerInfo, - type OrchestratorStatus, -} from "../lib/tauri"; -import { - clearOpenworkServerSettings, - createOpenworkServerClient, - normalizeOpenworkServerUrl, - writeOpenworkServerSettings, - type OpenworkAuditEntry, - type OpenworkServerCapabilities, - type OpenworkServerClient, - type OpenworkServerDiagnostics, - type OpenworkServerError, - type OpenworkServerSettings, - type OpenworkServerStatus, -} from "../lib/openwork-server"; - -export type OpenworkServerStore = ReturnType; - -type RemoteWorkspaceInput = { - openworkHostUrl: string; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; -}; - -export function createOpenworkServerStore(options: { - startupPreference: Accessor; - documentVisible: Accessor; - developerMode: Accessor; - runtimeWorkspaceId: Accessor; - activeClient: Accessor; - selectedWorkspaceDisplay: Accessor; - restartLocalServer: () => Promise; - createRemoteWorkspaceFlow: (input: RemoteWorkspaceInput) => Promise; -}) { - const bootStartedAt = Date.now(); - const [openworkServerSettings, setOpenworkServerSettings] = createSignal({}); - const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = createSignal(false); - const [shareRemoteAccessError, setShareRemoteAccessError] = createSignal(null); - const [openworkServerUrl, setOpenworkServerUrl] = createSignal(""); - const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected"); - const [openworkServerCapabilities, setOpenworkServerCapabilities] = - createSignal(null); - const [, setOpenworkServerCheckedAt] = createSignal(null); - const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal(null); - const [openworkServerHostInfoReady, setOpenworkServerHostInfoReady] = createSignal(!isTauriRuntime()); - const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = - createSignal(null); - const [openworkReconnectBusy, setOpenworkReconnectBusy] = createSignal(false); - const [opencodeRouterInfoState, setOpenCodeRouterInfoState] = - createSignal(null); - const [orchestratorStatusState, setOrchestratorStatusState] = - createSignal(null); - const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal([]); - const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle"); - const [openworkAuditError, setOpenworkAuditError] = createSignal(null); - const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal(null); - - const openworkServerBaseUrl = createMemo(() => { - const pref = options.startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; - - if (pref === "local") return hostInfo?.baseUrl ?? ""; - if (pref === "server") return settingsUrl; - return hostInfo?.baseUrl ?? settingsUrl; - }); - - const openworkServerAuth = createMemo( - () => { - const pref = options.startupPreference(); - const hostInfo = openworkServerHostInfo(); - const settingsToken = openworkServerSettings().token?.trim() ?? ""; - const clientToken = hostInfo?.clientToken?.trim() ?? ""; - const hostToken = hostInfo?.hostToken?.trim() ?? ""; - - if (pref === "local") { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - if (pref === "server") { - return { token: settingsToken || undefined, hostToken: undefined }; - } - if (hostInfo?.baseUrl) { - return { token: clientToken || undefined, hostToken: hostToken || undefined }; - } - return { token: settingsToken || undefined, hostToken: undefined }; - }, - undefined, - { - equals: (prev, next) => prev?.token === next.token && prev?.hostToken === next.hostToken, - }, - ); - - const openworkServerClient = createMemo(() => { - const baseUrl = openworkServerBaseUrl().trim(); - if (!baseUrl) return null; - const auth = openworkServerAuth(); - return createOpenworkServerClient({ baseUrl, token: auth.token, hostToken: auth.hostToken }); - }); - - const openworkServerReady = createMemo(() => openworkServerStatus() === "connected"); - const openworkServerWorkspaceReady = createMemo(() => Boolean(options.runtimeWorkspaceId())); - const resolvedOpenworkCapabilities = createMemo(() => openworkServerCapabilities()); - const openworkServerCanWriteSkills = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.skills?.write ?? false), - ); - const openworkServerCanWritePlugins = createMemo( - () => - openworkServerReady() && - openworkServerWorkspaceReady() && - (resolvedOpenworkCapabilities()?.plugins?.write ?? false), - ); - - const updateOpenworkServerSettings = (next: OpenworkServerSettings) => { - const stored = writeOpenworkServerSettings(next); - setOpenworkServerSettings(stored); - }; - - const resetOpenworkServerSettings = () => { - clearOpenworkServerSettings(); - setOpenworkServerSettings({}); - }; - - const checkOpenworkServer = async (url: string, token?: string, hostToken?: string) => { - const client = createOpenworkServerClient({ baseUrl: url, token, hostToken }); - try { - await client.health(); - } catch (error) { - const resolved = error as OpenworkServerError | Error; - if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - - if (!token) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - - try { - const caps = await client.capabilities(); - return { status: "connected" as OpenworkServerStatus, capabilities: caps }; - } catch (error) { - const resolved = error as OpenworkServerError | Error; - if ("status" in resolved && (resolved.status === 401 || resolved.status === 403)) { - return { status: "limited" as OpenworkServerStatus, capabilities: null }; - } - return { status: "disconnected" as OpenworkServerStatus, capabilities: null }; - } - }; - - const shouldWaitForLocalHostInfo = () => - isTauriRuntime() && - options.startupPreference() !== "server" && - !openworkServerHostInfoReady(); - - const shouldRetryStartupCheck = (status: OpenworkServerStatus) => - status !== "connected" && - isTauriRuntime() && - options.startupPreference() !== "server" && - Date.now() - bootStartedAt < 5_000; - - createEffect(() => { - const pref = options.startupPreference(); - const info = openworkServerHostInfo(); - const hostUrl = info?.connectUrl ?? info?.lanUrl ?? info?.mdnsUrl ?? info?.baseUrl ?? ""; - const settingsUrl = normalizeOpenworkServerUrl(openworkServerSettings().urlOverride ?? "") ?? ""; - - if (pref === "local") { - setOpenworkServerUrl(hostUrl); - return; - } - if (pref === "server") { - setOpenworkServerUrl(settingsUrl); - return; - } - setOpenworkServerUrl(hostUrl || settingsUrl); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!options.documentVisible()) return; - if (shouldWaitForLocalHostInfo()) return; - const url = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - const token = auth.token; - const hostToken = auth.hostToken; - - if (!url) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return; - } - - let active = true; - let busy = false; - let timeoutId: number | undefined; - let delayMs = 10_000; - - const scheduleNext = () => { - if (!active) return; - timeoutId = window.setTimeout(run, delayMs); - }; - - const run = async () => { - if (busy) return; - busy = true; - try { - let result = await checkOpenworkServer(url, token, hostToken); - - if (shouldRetryStartupCheck(result.status)) { - await new Promise((resolve) => window.setTimeout(resolve, 250)); - if (!active) return; - - try { - const info = await openworkServerInfo(); - if (!active) return; - - setOpenworkServerHostInfo(info); - setOpenworkServerHostInfoReady(true); - - const retryUrl = info.baseUrl?.trim() ?? ""; - const retryToken = info.clientToken?.trim() || undefined; - const retryHostToken = info.hostToken?.trim() || undefined; - if (retryUrl) { - result = await checkOpenworkServer(retryUrl, retryToken, retryHostToken); - } - } catch { - // ignore retry failures and surface the original result below - } - } - - if (!active) return; - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - delayMs = - result.status === "connected" || result.status === "limited" - ? 10_000 - : Math.min(delayMs * 2, 60_000); - } catch { - delayMs = Math.min(delayMs * 2, 60_000); - } finally { - if (!active) return; - setOpenworkServerCheckedAt(Date.now()); - busy = false; - scheduleNext(); - } - }; - - run(); - onCleanup(() => { - active = false; - if (timeoutId) window.clearTimeout(timeoutId); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!options.documentVisible()) return; - let active = true; - - const run = async () => { - try { - const info = await openworkServerInfo(); - if (active) setOpenworkServerHostInfo(info); - } catch { - if (active) setOpenworkServerHostInfo(null); - } finally { - if (active) setOpenworkServerHostInfoReady(true); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - const hostInfo = openworkServerHostInfo(); - const port = hostInfo?.port; - if (!port) return; - - const current = openworkServerSettings(); - if (current.portOverride === port) return; - - updateOpenworkServerSettings({ - ...current, - portOverride: port, - }); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!options.documentVisible()) return; - if (!options.developerMode()) { - setOpenworkServerDiagnostics(null); - return; - } - - const client = openworkServerClient(); - if (!client || openworkServerStatus() === "disconnected") { - setOpenworkServerDiagnostics(null); - return; - } - - let active = true; - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - try { - const status = await client.status(); - if (active) setOpenworkServerDiagnostics(status); - } catch { - if (active) setOpenworkServerDiagnostics(null); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!options.developerMode()) { - setOpenCodeRouterInfoState(null); - return; - } - if (!options.documentVisible()) return; - - let active = true; - - const run = async () => { - try { - const info = await opencodeRouterInfo(); - if (active) setOpenCodeRouterInfoState(info); - } catch { - if (active) setOpenCodeRouterInfoState(null); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!isTauriRuntime()) return; - if (!options.developerMode()) { - setOrchestratorStatusState(null); - return; - } - if (!options.documentVisible()) return; - - let active = true; - - const run = async () => { - try { - const status = await orchestratorStatus(); - if (active) setOrchestratorStatusState(status); - } catch { - if (active) setOrchestratorStatusState(null); - } - }; - - run(); - const interval = window.setInterval(run, 10_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!options.developerMode()) { - setDevtoolsWorkspaceId(null); - return; - } - if (!options.documentVisible()) return; - - const client = openworkServerClient(); - if (!client) { - setDevtoolsWorkspaceId(null); - return; - } - let active = true; - - const run = async () => { - try { - const response = await client.listWorkspaces(); - if (!active) return; - const items = Array.isArray(response.items) ? response.items : []; - const activeMatch = response.activeId ? items.find((item) => item.id === response.activeId) : null; - setDevtoolsWorkspaceId(activeMatch?.id ?? items[0]?.id ?? null); - } catch { - if (active) setDevtoolsWorkspaceId(null); - } - }; - - run(); - const interval = window.setInterval(run, 20_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - createEffect(() => { - if (!options.developerMode()) { - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("idle"); - setOpenworkAuditError(null); - return; - } - if (!options.documentVisible()) return; - - const client = openworkServerClient(); - const workspaceId = devtoolsWorkspaceId(); - if (!client || !workspaceId) { - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("idle"); - setOpenworkAuditError(null); - return; - } - - let active = true; - let busy = false; - - const run = async () => { - if (busy) return; - busy = true; - setOpenworkAuditStatus("loading"); - setOpenworkAuditError(null); - try { - const result = await client.listAudit(workspaceId, 50); - if (!active) return; - setOpenworkAuditEntries(Array.isArray(result.items) ? result.items : []); - setOpenworkAuditStatus("idle"); - } catch (error) { - if (!active) return; - setOpenworkAuditEntries([]); - setOpenworkAuditStatus("error"); - setOpenworkAuditError(error instanceof Error ? error.message : t("app.error_audit_load", currentLocale())); - } finally { - busy = false; - } - }; - - run(); - const interval = window.setInterval(run, 15_000); - onCleanup(() => { - active = false; - window.clearInterval(interval); - }); - }); - - const testOpenworkServerConnection = async (next: OpenworkServerSettings) => { - const derived = normalizeOpenworkServerUrl(next.urlOverride ?? ""); - if (!derived) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return false; - } - - const result = await checkOpenworkServer(derived, next.token); - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - setOpenworkServerCheckedAt(Date.now()); - - const ok = result.status === "connected" || result.status === "limited"; - if (ok && !isTauriRuntime()) { - const active = options.selectedWorkspaceDisplay(); - const shouldAttach = !options.activeClient() || active.workspaceType !== "remote" || active.remoteType !== "openwork"; - if (shouldAttach) { - await options.createRemoteWorkspaceFlow({ - openworkHostUrl: derived, - openworkToken: next.token ?? null, - }).catch(() => undefined); - } - } - return ok; - }; - - const reconnectOpenworkServer = async () => { - if (openworkReconnectBusy()) return false; - setOpenworkReconnectBusy(true); - try { - let hostInfo = openworkServerHostInfo(); - if (isTauriRuntime()) { - try { - hostInfo = await openworkServerInfo(); - setOpenworkServerHostInfo(hostInfo); - } catch { - hostInfo = null; - setOpenworkServerHostInfo(null); - } - } - - if (hostInfo?.clientToken?.trim() && options.startupPreference() !== "server") { - const liveToken = hostInfo.clientToken.trim(); - const settings = openworkServerSettings(); - if ((settings.token?.trim() ?? "") !== liveToken) { - updateOpenworkServerSettings({ ...settings, token: liveToken }); - } - } - - const url = openworkServerBaseUrl().trim(); - const auth = openworkServerAuth(); - if (!url) { - setOpenworkServerStatus("disconnected"); - setOpenworkServerCapabilities(null); - setOpenworkServerCheckedAt(Date.now()); - return false; - } - - const result = await checkOpenworkServer(url, auth.token, auth.hostToken); - setOpenworkServerStatus(result.status); - setOpenworkServerCapabilities(result.capabilities); - setOpenworkServerCheckedAt(Date.now()); - return result.status === "connected" || result.status === "limited"; - } finally { - setOpenworkReconnectBusy(false); - } - }; - - async function ensureLocalOpenworkServerClient(): Promise { - let hostInfo = openworkServerHostInfo(); - if (hostInfo?.baseUrl?.trim() && hostInfo.clientToken?.trim()) { - const existing = createOpenworkServerClient({ - baseUrl: hostInfo.baseUrl.trim(), - token: hostInfo.clientToken.trim(), - hostToken: hostInfo.hostToken?.trim() || undefined, - }); - try { - await existing.health(); - if (options.startupPreference() !== "server") { - await reconnectOpenworkServer(); - } - return existing; - } catch { - // restart below - } - } - - if (!isTauriRuntime()) { - return null; - } - - try { - hostInfo = await openworkServerRestart({ - remoteAccessEnabled: openworkServerSettings().remoteAccessEnabled === true, - }); - setOpenworkServerHostInfo(hostInfo); - } catch { - return null; - } - - const baseUrl = hostInfo?.baseUrl?.trim() ?? ""; - const token = hostInfo?.clientToken?.trim() ?? ""; - const hostToken = hostInfo?.hostToken?.trim() ?? ""; - if (!baseUrl || !token) { - return null; - } - - if (options.startupPreference() !== "server") { - await reconnectOpenworkServer(); - } - - return createOpenworkServerClient({ - baseUrl, - token, - hostToken: hostToken || undefined, - }); - } - - const saveShareRemoteAccess = async (enabled: boolean) => { - if (shareRemoteAccessBusy()) return; - const previous = openworkServerSettings(); - const next: OpenworkServerSettings = { - ...previous, - remoteAccessEnabled: enabled, - }; - - setShareRemoteAccessBusy(true); - setShareRemoteAccessError(null); - updateOpenworkServerSettings(next); - - try { - if (isTauriRuntime() && options.selectedWorkspaceDisplay().workspaceType === "local") { - const restarted = await options.restartLocalServer(); - if (!restarted) { - throw new Error(t("app.error_restart_local_worker", currentLocale())); - } - await reconnectOpenworkServer(); - } - } catch (error) { - updateOpenworkServerSettings(previous); - setShareRemoteAccessError( - error instanceof Error - ? error.message - : t("app.error_remote_access", currentLocale()), - ); - return; - } finally { - setShareRemoteAccessBusy(false); - } - }; - - return { - openworkServerSettings, - setOpenworkServerSettings, - updateOpenworkServerSettings, - resetOpenworkServerSettings, - shareRemoteAccessBusy, - shareRemoteAccessError, - saveShareRemoteAccess, - openworkServerUrl, - openworkServerBaseUrl, - openworkServerAuth, - openworkServerClient, - openworkServerStatus, - openworkServerCapabilities, - openworkServerReady, - openworkServerWorkspaceReady, - resolvedOpenworkCapabilities, - openworkServerCanWriteSkills, - openworkServerCanWritePlugins, - openworkServerHostInfo, - openworkServerDiagnostics, - openworkReconnectBusy, - opencodeRouterInfoState, - orchestratorStatusState, - openworkAuditEntries, - openworkAuditStatus, - openworkAuditError, - devtoolsWorkspaceId, - checkOpenworkServer, - testOpenworkServerConnection, - reconnectOpenworkServer, - ensureLocalOpenworkServerClient, - }; -} diff --git a/apps/app/src/app/connections/provider.tsx b/apps/app/src/app/connections/provider.tsx deleted file mode 100644 index 723d083b..00000000 --- a/apps/app/src/app/connections/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ConnectionsStore } from "./store"; - -const ConnectionsContext = createContext(); - -export function ConnectionsProvider(props: ParentProps<{ store: ConnectionsStore }>) { - return ( - - {props.children} - - ); -} - -export function useConnections() { - const context = useContext(ConnectionsContext); - if (!context) { - throw new Error("useConnections must be used within a ConnectionsProvider"); - } - return context; -} diff --git a/apps/app/src/app/context/automations.ts b/apps/app/src/app/context/automations.ts deleted file mode 100644 index 8ad926ff..00000000 --- a/apps/app/src/app/context/automations.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { createEffect, createMemo, createSignal } from "solid-js"; - -import type { ScheduledJob } from "../types"; -import { schedulerDeleteJob, schedulerListJobs } from "../lib/tauri"; -import { isTauriRuntime } from "../utils"; -import { createWorkspaceContextKey } from "./workspace-context"; -import type { OpenworkServerStore } from "../connections/openwork-server-store"; -import { t } from "../../i18n"; - -export type AutomationsStore = ReturnType; - -export type AutomationActionPlan = - | { ok: true; mode: "session_prompt"; prompt: string } - | { ok: false; error: string }; - -export type PrepareCreateAutomationInput = { - name: string; - prompt: string; - schedule: string; - workdir?: string | null; -}; - -const normalizeSentence = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return ""; - if (/[.!?]$/.test(trimmed)) return trimmed; - return `${trimmed}.`; -}; - -const buildCreateAutomationPrompt = ( - input: PrepareCreateAutomationInput, -): AutomationActionPlan => { - const name = input.name.trim(); - const schedule = input.schedule.trim(); - const prompt = normalizeSentence(input.prompt); - if (!schedule) { - return { ok: false, error: t("automations.schedule_required") }; - } - if (!prompt) { - return { ok: false, error: t("automations.prompt_required") }; - } - const workdir = (input.workdir ?? "").trim(); - const nameSegment = name ? ` named \"${name}\"` : ""; - const workdirSegment = workdir ? ` Run from ${workdir}.` : ""; - return { - ok: true, - mode: "session_prompt", - prompt: `Schedule a job${nameSegment} with cron \"${schedule}\" to ${prompt}${workdirSegment}`.trim(), - }; -}; - -const buildRunAutomationPrompt = ( - job: ScheduledJob, - fallbackWorkdir?: string | null, -): AutomationActionPlan => { - const workdir = (job.workdir ?? fallbackWorkdir ?? "").trim(); - const workdirSegment = workdir ? `\n\nRun from ${workdir}.` : ""; - - if (job.run?.prompt || job.prompt) { - const promptBody = (job.run?.prompt ?? job.prompt ?? "").trim(); - if (!promptBody) { - return { ok: false, error: t("automations.prompt_empty") }; - } - return { - ok: true, - mode: "session_prompt", - prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.\n\n${promptBody}${workdirSegment}`.trim(), - }; - } - - if (job.run?.command) { - const args = job.run.arguments ? ` ${job.run.arguments}` : ""; - const command = `${job.run.command}${args}`.trim(); - return { - ok: true, - mode: "session_prompt", - prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.\n\nRun the following command:\n${command}${workdirSegment}`.trim(), - }; - } - - return { - ok: true, - mode: "session_prompt", - prompt: `Run this automation now: ${job.name}.\nSchedule: ${job.schedule}.`.trim(), - }; -}; - -export function createAutomationsStore(options: { - selectedWorkspaceId: () => string; - selectedWorkspaceRoot: () => string; - runtimeWorkspaceId: () => string | null; - openworkServer: OpenworkServerStore; - schedulerPluginInstalled: () => boolean; -}) { - const [scheduledJobs, setScheduledJobs] = createSignal([]); - const [scheduledJobsStatus, setScheduledJobsStatus] = createSignal(null); - const [scheduledJobsBusy, setScheduledJobsBusy] = createSignal(false); - const [scheduledJobsUpdatedAt, setScheduledJobsUpdatedAt] = createSignal(null); - const [pendingRefreshContextKey, setPendingRefreshContextKey] = createSignal(null); - - const serverBacked = createMemo(() => { - const client = options.openworkServer.openworkServerClient(); - const runtimeWorkspaceId = (options.runtimeWorkspaceId() ?? "").trim(); - return options.openworkServer.openworkServerStatus() === "connected" && Boolean(client && runtimeWorkspaceId); - }); - - const scheduledJobsSource = createMemo<"local" | "remote">(() => - serverBacked() ? "remote" : "local", - ); - - const scheduledJobsContextKey = createWorkspaceContextKey({ - selectedWorkspaceId: options.selectedWorkspaceId, - selectedWorkspaceRoot: options.selectedWorkspaceRoot, - runtimeWorkspaceId: options.runtimeWorkspaceId, - }); - - const scheduledJobsPollingAvailable = createMemo(() => { - if (scheduledJobsSource() === "remote") return true; - return isTauriRuntime() && options.schedulerPluginInstalled(); - }); - - const refreshScheduledJobs = async ( - _options?: { force?: boolean }, - ): Promise<"success" | "error" | "unavailable" | "skipped"> => { - const requestContextKey = scheduledJobsContextKey(); - if (!requestContextKey) return "skipped"; - - if (scheduledJobsBusy()) { - setPendingRefreshContextKey(requestContextKey); - return "skipped"; - } - - if (scheduledJobsSource() === "remote") { - const client = options.openworkServer.openworkServerClient(); - const workspaceId = (options.runtimeWorkspaceId() ?? "").trim(); - if (!client || options.openworkServer.openworkServerStatus() !== "connected" || !workspaceId) { - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - const status = - options.openworkServer.openworkServerStatus() === "disconnected" - ? t("automations.server_unavailable") - : options.openworkServer.openworkServerStatus() === "limited" - ? t("automations.server_needs_token") - : t("automations.server_not_ready"); - setScheduledJobsStatus(status); - return "unavailable"; - } - - setScheduledJobsBusy(true); - setScheduledJobsStatus(null); - try { - const response = await client.listScheduledJobs(workspaceId); - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - setScheduledJobs(Array.isArray(response.items) ? response.items : []); - setScheduledJobsUpdatedAt(Date.now()); - return "success"; - } catch (error) { - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - const message = error instanceof Error ? error.message : String(error); - setScheduledJobsStatus(message || t("automations.failed_to_load")); - return "error"; - } finally { - setScheduledJobsBusy(false); - } - } - - if (!isTauriRuntime() || !options.schedulerPluginInstalled()) { - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - setScheduledJobsStatus(null); - return "unavailable"; - } - - setScheduledJobsBusy(true); - setScheduledJobsStatus(null); - try { - const root = options.selectedWorkspaceRoot().trim(); - const jobs = await schedulerListJobs(root || undefined); - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - setScheduledJobs(jobs); - setScheduledJobsUpdatedAt(Date.now()); - return "success"; - } catch (error) { - if (scheduledJobsContextKey() !== requestContextKey) return "skipped"; - const message = error instanceof Error ? error.message : String(error); - setScheduledJobsStatus(message || t("automations.failed_to_load")); - return "error"; - } finally { - setScheduledJobsBusy(false); - } - }; - - const deleteScheduledJob = async (name: string) => { - if (scheduledJobsSource() === "remote") { - const client = options.openworkServer.openworkServerClient(); - const workspaceId = (options.runtimeWorkspaceId() ?? "").trim(); - if (!client || !workspaceId) { - throw new Error(t("automations.server_unavailable")); - } - const response = await client.deleteScheduledJob(workspaceId, name); - setScheduledJobs((current) => current.filter((entry) => entry.slug !== response.job.slug)); - return; - } - - if (!isTauriRuntime()) { - throw new Error(t("automations.desktop_required")); - } - const root = options.selectedWorkspaceRoot().trim(); - const job = await schedulerDeleteJob(name, root || undefined); - setScheduledJobs((current) => current.filter((entry) => entry.slug !== job.slug)); - }; - - const prepareCreateAutomation = (input: PrepareCreateAutomationInput) => - buildCreateAutomationPrompt(input); - - const prepareRunAutomation = ( - job: ScheduledJob, - fallbackWorkdir?: string | null, - ) => buildRunAutomationPrompt(job, fallbackWorkdir); - - createEffect(() => { - scheduledJobsContextKey(); - setScheduledJobs([]); - setScheduledJobsStatus(null); - setScheduledJobsUpdatedAt(null); - setPendingRefreshContextKey(null); - }); - - createEffect(() => { - const key = scheduledJobsContextKey(); - if (!key) return; - if (scheduledJobsBusy()) return; - if (scheduledJobsUpdatedAt()) return; - void refreshScheduledJobs(); - }); - - createEffect(() => { - const pending = pendingRefreshContextKey(); - if (!pending) return; - if (scheduledJobsBusy()) return; - if (pending !== scheduledJobsContextKey()) { - setPendingRefreshContextKey(scheduledJobsContextKey()); - return; - } - setPendingRefreshContextKey(null); - void refreshScheduledJobs(); - }); - - return { - scheduledJobs, - scheduledJobsStatus, - scheduledJobsBusy, - scheduledJobsUpdatedAt, - scheduledJobsSource, - scheduledJobsPollingAvailable, - scheduledJobsContextKey, - refreshScheduledJobs, - deleteScheduledJob, - jobs: scheduledJobs, - jobsStatus: scheduledJobsStatus, - jobsBusy: scheduledJobsBusy, - jobsUpdatedAt: scheduledJobsUpdatedAt, - jobsSource: scheduledJobsSource, - pollingAvailable: scheduledJobsPollingAvailable, - refresh: refreshScheduledJobs, - remove: deleteScheduledJob, - prepareCreateAutomation, - prepareRunAutomation, - }; -} diff --git a/apps/app/src/app/context/global-sdk.tsx b/apps/app/src/app/context/global-sdk.tsx deleted file mode 100644 index 7075ad4c..00000000 --- a/apps/app/src/app/context/global-sdk.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"; -import { createGlobalEmitter } from "@solid-primitives/event-bus"; -import { - batch, - createContext, - createEffect, - createSignal, - onCleanup, - useContext, - type ParentProps, -} from "solid-js"; - -import { usePlatform } from "./platform"; -import { useServer } from "./server"; - -type GlobalSDKContextValue = { - url: () => string; - client: () => ReturnType; - event: ReturnType>; -}; - -const GlobalSDKContext = createContext(undefined); - -export function GlobalSDKProvider(props: ParentProps) { - const server = useServer(); - const platform = usePlatform(); - const emitter = createGlobalEmitter<{ [key: string]: Event }>(); - const [client, setClient] = createSignal( - createOpencodeClient({ - baseUrl: server.url, - fetch: platform.fetch, - throwOnError: true, - }), - ); - const [url, setUrl] = createSignal(server.url); - - createEffect(() => { - const baseUrl = server.url; - const isHealthy = server.healthy() === true; - - const token = (() => { - if (typeof window === "undefined") return ""; - try { - return (window.localStorage.getItem("openwork.server.token") ?? "").trim(); - } catch { - return ""; - } - })(); - const headers = token && baseUrl.includes("/opencode") ? { Authorization: `Bearer ${token}` } : undefined; - setUrl(baseUrl); - - // Always keep the request client in sync with the active URL. - setClient( - createOpencodeClient({ - baseUrl, - headers, - fetch: platform.fetch, - throwOnError: true, - }), - ); - - // Avoid silent retry loops (SSE reconnects) when the dependency is unavailable. - if (!baseUrl || !isHealthy) { - return; - } - - const abort = new AbortController(); - const eventClient = createOpencodeClient({ - baseUrl, - headers, - signal: abort.signal, - fetch: platform.fetch, - }); - - type Queued = { directory: string; payload: Event }; - - let queue: Array = []; - const coalesced = new Map(); - let timer: ReturnType | undefined; - let last = 0; - - const keyForEvent = (directory: string, payload: Event) => { - if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`; - if (payload.type === "lsp.updated") return `lsp.updated:${directory}`; - if (payload.type === "todo.updated") return `todo.updated:${directory}:${payload.properties.sessionID}`; - if (payload.type === "mcp.tools.changed") return `mcp.tools.changed:${directory}:${payload.properties.server}`; - if (payload.type === "message.part.updated") { - const part = payload.properties.part; - return `message.part.updated:${directory}:${part.messageID}:${part.id}`; - } - }; - - const flush = () => { - if (timer) clearTimeout(timer); - timer = undefined; - - const events = queue; - queue = []; - coalesced.clear(); - if (events.length === 0) return; - - last = Date.now(); - batch(() => { - for (const entry of events) { - if (!entry) continue; - emitter.emit(entry.directory, entry.payload); - } - }); - }; - - const schedule = () => { - if (timer) return; - const elapsed = Date.now() - last; - timer = setTimeout(flush, Math.max(0, 16 - elapsed)); - }; - - const stop = () => { - flush(); - }; - - void (async () => { - const subscription = await eventClient.event.subscribe(undefined, { signal: abort.signal }); - let yielded = Date.now(); - - for await (const event of subscription.stream as AsyncIterable) { - const record = event as Event & { directory?: string; payload?: Event }; - const payload = record.payload ?? record; - if (!payload?.type) continue; - - const directory = typeof record.directory === "string" ? record.directory : "global"; - const key = keyForEvent(directory, payload); - if (key) { - const index = coalesced.get(key); - if (index !== undefined) { - queue[index] = undefined; - } - coalesced.set(key, queue.length); - } - - queue.push({ directory, payload }); - schedule(); - - if (Date.now() - yielded < 8) continue; - yielded = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - })() - .finally(stop) - .catch(() => undefined); - - onCleanup(() => { - abort.abort(); - stop(); - }); - }); - - const value: GlobalSDKContextValue = { - url, - client, - event: emitter, - }; - - return {props.children}; -} - -export function useGlobalSDK() { - const context = useContext(GlobalSDKContext); - if (!context) { - throw new Error("Global SDK context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/global-sync.tsx b/apps/app/src/app/context/global-sync.tsx deleted file mode 100644 index 4b73134b..00000000 --- a/apps/app/src/app/context/global-sync.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { createContext, createEffect, useContext, type ParentProps } from "solid-js"; - -import { t, currentLocale } from "../../i18n"; -import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; - -import type { - Config, - ConfigProvidersResponse, - Event, - GlobalHealthResponse, - LspStatus, - Project, - ProviderListResponse, - ProviderAuthResponse, - Message, - Part, - Session, - VcsInfo, -} from "@opencode-ai/sdk/v2/client"; - -import type { McpStatusMap, TodoItem } from "../types"; -import { unwrap } from "../lib/opencode"; -import { safeStringify } from "../utils"; -import { filterProviderList, mapConfigProvidersToList } from "../utils/providers"; -import { useGlobalSDK } from "./global-sdk"; - -export type WorkspaceState = { - status: "idle" | "loading" | "partial" | "ready"; - session: Session[]; - session_status: Record; - message: Record; - part: Record; - todo: Record; -}; - -type WorkspaceStore = [Store, SetStoreFunction]; - -type ProjectMeta = { - name?: string; - icon?: Project["icon"]; -}; - -type GlobalState = { - ready: boolean; - error?: string; - serverVersion?: string; - config: Config; - provider: ProviderListResponse; - providerAuth: ProviderAuthResponse; - mcp: Record; - lsp: Record; - project: Project[]; - projectMeta: Record; - vcs: Record; -}; - -type GlobalSyncContextValue = { - data: Store; - set: SetStoreFunction; - child: (directory: string) => WorkspaceStore; - refresh: () => Promise; - refreshDirectory: (directory: string) => Promise; -}; - -const GlobalSyncContext = createContext(undefined); - -const createWorkspaceState = (): WorkspaceState => ({ - status: "idle", - session: [], - session_status: {}, - message: {}, - part: {}, - todo: {}, -}); - -export function GlobalSyncProvider(props: ParentProps) { - const globalSDK = useGlobalSDK(); - const defaultProvider: ProviderListResponse = { all: [], connected: [], default: {} }; - const [globalStore, setGlobalStore] = createStore({ - ready: false, - error: undefined, - serverVersion: undefined, - config: {}, - provider: defaultProvider, - providerAuth: {}, - mcp: {}, - lsp: {}, - project: [], - projectMeta: {}, - vcs: {}, - }); - const children = new Map(); - const subscriptions = new Map void>(); - - const keyFor = (directory: string) => directory || "global"; - - const setError = (error: unknown) => { - const message = error instanceof Error ? error.message : safeStringify(error); - setGlobalStore("error", message || t("app.unknown_error", currentLocale())); - }; - - const setProjectMeta = (projects: Project[]) => { - const next: Record = {}; - for (const project of projects) { - if (!project?.worktree) continue; - next[project.worktree] = { - name: project.name, - icon: project.icon, - }; - } - setGlobalStore("projectMeta", next); - }; - - const refreshConfig = async () => { - const result = unwrap(await globalSDK.client().config.get()); - setGlobalStore("config", result); - }; - - const refreshProviders = async () => { - let disabledProviders = globalStore.config.disabled_providers ?? []; - try { - const config = unwrap(await globalSDK.client().config.get()); - disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; - } catch { - // ignore config read failures and continue with current store state - } - try { - const result = filterProviderList( - unwrap(await globalSDK.client().provider.list()), - disabledProviders, - ); - setGlobalStore("provider", result); - } catch { - const fallback = unwrap(await globalSDK.client().config.providers()) as ConfigProvidersResponse; - setGlobalStore( - "provider", - filterProviderList( - { - all: mapConfigProvidersToList(fallback.providers), - connected: [], - default: fallback.default, - }, - disabledProviders, - ), - ); - } - }; - - const refreshProviderAuth = async () => { - try { - const result = await globalSDK.client().provider.auth(); - setGlobalStore("providerAuth", result.data ?? {}); - } catch { - setGlobalStore("providerAuth", {}); - } - }; - - const refreshMcp = async (directory?: string) => { - const result = unwrap(await globalSDK.client().mcp.status({ directory })) as McpStatusMap; - setGlobalStore("mcp", keyFor(directory ?? ""), result as McpStatusMap); - }; - - const refreshLsp = async (directory?: string) => { - const result = unwrap(await globalSDK.client().lsp.status({ directory })) as LspStatus[]; - setGlobalStore("lsp", keyFor(directory ?? ""), result as LspStatus[]); - }; - - const refreshVcs = async (directory: string) => { - try { - const result = unwrap(await globalSDK.client().vcs.get({ directory })) as VcsInfo; - setGlobalStore("vcs", keyFor(directory), result ?? null); - } catch { - setGlobalStore("vcs", keyFor(directory), null); - } - }; - - const refreshProjects = async () => { - const projects = unwrap(await globalSDK.client().project.list()) as Project[]; - setGlobalStore("project", projects); - setProjectMeta(projects); - await Promise.allSettled( - projects - .map((project) => project.worktree) - .filter((worktree): worktree is string => typeof worktree === "string" && worktree.length > 0) - .map((worktree) => refreshVcs(worktree)), - ); - }; - - const refreshDirectory = async (directory: string) => { - if (!directory) return; - await Promise.allSettled([ - refreshMcp(directory), - refreshLsp(directory), - refreshVcs(directory), - ]); - }; - - const refresh = async () => { - setGlobalStore("ready", false); - setGlobalStore("error", undefined); - - try { - const health = unwrap(await globalSDK.client().global.health()) as GlobalHealthResponse; - if (!health?.healthy) { - setGlobalStore("error", "Server reported unhealthy status."); - return; - } - - if (globalStore.serverVersion && health.version !== globalStore.serverVersion) { - setGlobalStore("mcp", {}); - setGlobalStore("lsp", {}); - setGlobalStore("project", []); - setGlobalStore("projectMeta", {}); - setGlobalStore("vcs", {}); - } - setGlobalStore("serverVersion", health.version); - } catch (error) { - setError(error); - return; - } - - const results = await Promise.allSettled([ - refreshConfig(), - refreshProviders(), - refreshProviderAuth(), - refreshMcp(), - refreshLsp(), - refreshProjects(), - ]); - - for (const result of results) { - if (result.status === "rejected") { - setError(result.reason); - } - } - - setGlobalStore("ready", true); - }; - - const child = (directory: string): WorkspaceStore => { - const key = keyFor(directory); - const existing = children.get(key); - if (existing) return existing; - const store = createStore(createWorkspaceState()); - children.set(key, store); - void refreshDirectory(directory); - if (!subscriptions.has(key)) { - const unsubscribe = globalSDK.event.listen((payload) => { - if (payload.name !== key) return; - const event = payload.details as Event; - if (event.type === "lsp.updated") { - void refreshLsp(directory); - } - if (event.type === "mcp.tools.changed") { - void refreshMcp(directory); - } - }); - subscriptions.set(key, unsubscribe); - } - return store; - }; - - const value: GlobalSyncContextValue = { - data: globalStore, - set: setGlobalStore, - child, - refresh, - refreshDirectory, - }; - - createEffect(() => { - const url = globalSDK.url(); - if (!url) return; - void refresh(); - }); - - const globalKey = keyFor(""); - if (!subscriptions.has(globalKey)) { - const unsubscribe = globalSDK.event.listen((payload) => { - if (payload.name !== globalKey) return; - const event = payload.details as Event; - if (event.type === "lsp.updated") { - void refreshLsp(); - } - if (event.type === "mcp.tools.changed") { - void refreshMcp(); - } - }); - subscriptions.set(globalKey, unsubscribe); - } - - return {props.children}; -} - -export function useGlobalSync() { - const context = useContext(GlobalSyncContext); - if (!context) { - throw new Error("Global sync context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/local.tsx b/apps/app/src/app/context/local.tsx deleted file mode 100644 index 268a260f..00000000 --- a/apps/app/src/app/context/local.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { createContext, createEffect, useContext, type ParentProps } from "solid-js"; -import { createStore, type SetStoreFunction, type Store } from "solid-js/store"; - -import { THINKING_PREF_KEY } from "../constants"; -import type { ModelRef, SettingsTab, View } from "../types"; -import { Persist, persisted } from "../utils/persist"; - -type LocalUIState = { - view: View; - tab: SettingsTab; -}; - -type LocalPreferences = { - showThinking: boolean; - modelVariant: string | null; - defaultModel: ModelRef | null; - featureFlags: { - microsandboxCreateSandbox: boolean; - }; -}; - -type LocalContextValue = { - ui: Store; - setUi: SetStoreFunction; - prefs: Store; - setPrefs: SetStoreFunction; - ready: () => boolean; -}; - -const LocalContext = createContext(undefined); - -export function LocalProvider(props: ParentProps) { - const [ui, setUi, , uiReady] = persisted( - Persist.global("local.ui", ["openwork.ui"]), - createStore({ - view: "settings", - tab: "general", - }), - ); - - const [prefs, setPrefs, , prefsReady] = persisted( - Persist.global("local.preferences", ["openwork.preferences"]), - createStore({ - showThinking: false, - modelVariant: null, - defaultModel: null, - featureFlags: { - microsandboxCreateSandbox: false, - }, - }), - ); - - const ready = () => uiReady() && prefsReady(); - - createEffect(() => { - if (!prefsReady()) return; - if (typeof window === "undefined") return; - - const raw = window.localStorage.getItem(THINKING_PREF_KEY); - if (raw == null) return; - - try { - const parsed = JSON.parse(raw); - if (typeof parsed === "boolean") { - setPrefs("showThinking", parsed); - } - } catch { - // ignore invalid legacy values - } - - try { - window.localStorage.removeItem(THINKING_PREF_KEY); - } catch { - // ignore - } - }); - - const value: LocalContextValue = { - ui, - setUi, - prefs, - setPrefs, - ready, - }; - - return {props.children}; -} - -export function useLocal() { - const context = useContext(LocalContext); - if (!context) { - throw new Error("Local context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/model-config.ts b/apps/app/src/app/context/model-config.ts deleted file mode 100644 index 887fce89..00000000 --- a/apps/app/src/app/context/model-config.ts +++ /dev/null @@ -1,1434 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; - -import { parse } from "jsonc-parser"; - -import { currentLocale, t } from "../../i18n"; -import { DEFAULT_MODEL, MODEL_PREF_KEY, SESSION_MODEL_PREF_KEY, VARIANT_PREF_KEY } from "../constants"; -import { - isDesktopModelBlocked, - type DesktopAppRestrictionChecker, -} from "../cloud/desktop-app-restrictions"; -import { readOpencodeConfig, writeOpencodeConfig } from "../lib/tauri"; -import { - formatGenericBehaviorLabel, - getModelBehaviorSummary, - normalizeModelBehaviorValue, - sanitizeModelBehaviorValue, -} from "../lib/model-behavior"; -import type { - OpenworkServerCapabilities, - OpenworkServerClient, - OpenworkServerStatus, -} from "../lib/openwork-server"; -import type { - Client, - MessageWithParts, - ModelOption, - ModelRef, - ProviderListItem, - WorkspaceDisplay, -} from "../types"; -import { - addOpencodeCacheHint, - formatModelLabel, - formatModelRef, - isTauriRuntime, - lastUserModelFromMessages, - modelEquals, - parseModelRef, - safeStringify, -} from "../utils"; -import { compareProviders, providerPriorityRank } from "../utils/providers"; - -export type SessionChoiceOverride = { - model?: ModelRef | null; - variant?: string | null; -}; - -export type SessionModelState = { - overrides: Record; - resolved: Record; -}; - -export type ModelPickerTarget = "default" | "session"; -export type PromptFocusReturnTarget = "none" | "composer"; - -const hasOwn = (value: object, key: K): value is Record => - Object.prototype.hasOwnProperty.call(value, key); - -const normalizeVariantOverride = (value: unknown) => { - if (typeof value === "string") return normalizeModelBehaviorValue(value); - if (value == null) return null; - return null; -}; - -const parseStoredModel = (value: unknown) => { - if (typeof value === "string") return parseModelRef(value); - if (!value || typeof value !== "object") return null; - - const record = value as Record; - if (typeof record.providerID === "string" && typeof record.modelID === "string") { - return { - providerID: record.providerID, - modelID: record.modelID, - }; - } - - return null; -}; - -const normalizeSessionChoice = (value: SessionChoiceOverride | null | undefined) => { - if (!value || typeof value !== "object") return null; - - const next: SessionChoiceOverride = {}; - if (value.model) { - next.model = value.model; - } - - if (hasOwn(value, "variant")) { - next.variant = normalizeModelBehaviorValue(value.variant ?? null); - } - - return hasOwn(next, "variant") || next.model ? next : null; -}; - -const deriveSessionModelOverrides = (choices: Record) => { - const next: Record = {}; - for (const [sessionId, choice] of Object.entries(choices)) { - if (choice.model) next[sessionId] = choice.model; - } - return next; -}; - -const applySessionModelState = ( - currentChoices: Record, - nextState: SessionModelState, -) => { - const nextChoices: Record = {}; - - for (const [sessionId, choice] of Object.entries(currentChoices)) { - if (hasOwn(choice, "variant") && !nextState.overrides[sessionId]) { - nextChoices[sessionId] = { variant: choice.variant ?? null }; - } - } - - for (const [sessionId, model] of Object.entries(nextState.overrides)) { - const current = currentChoices[sessionId]; - const nextChoice = normalizeSessionChoice({ - model, - ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), - }); - if (nextChoice) nextChoices[sessionId] = nextChoice; - } - - return nextChoices; -}; - -const parseDefaultModelFromConfig = (content: string | null) => { - if (!content) return null; - try { - const parsed = parse(content) as Record | undefined; - const rawModel = typeof parsed?.model === "string" ? parsed.model : null; - return parseModelRef(rawModel); - } catch { - return null; - } -}; - -const formatConfigWithDefaultModel = (content: string | null, model: ModelRef) => { - let config: Record = {}; - if (content?.trim()) { - try { - const parsed = parse(content) as Record | undefined; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - config = { ...parsed }; - } - } catch { - config = {}; - } - } - - if (!config["$schema"]) { - config["$schema"] = "https://opencode.ai/config.json"; - } - - config.model = formatModelRef(model); - return `${JSON.stringify(config, null, 2)}\n`; -}; - -const parseAutoCompactContextFromConfig = (content: string | null) => { - if (!content) return null; - try { - const parsed = parse(content) as Record | undefined; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - const compaction = parsed.compaction; - if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) { - return null; - } - return typeof (compaction as Record).auto === "boolean" - ? ((compaction as Record).auto as boolean) - : null; - } catch { - return null; - } -}; - -const formatConfigWithAutoCompactContext = (content: string | null, enabled: boolean) => { - let config: Record = {}; - if (content?.trim()) { - try { - const parsed = parse(content) as Record | undefined; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - config = { ...parsed }; - } - } catch { - config = {}; - } - } - - if (!config["$schema"]) { - config["$schema"] = "https://opencode.ai/config.json"; - } - - const compaction = - typeof config.compaction === "object" && config.compaction && !Array.isArray(config.compaction) - ? { ...(config.compaction as Record) } - : {}; - - compaction.auto = enabled; - config.compaction = compaction; - return `${JSON.stringify(config, null, 2)}\n`; -}; - -const getConfigSnapshot = (content: string | null) => { - if (!content?.trim()) return ""; - try { - const parsed = parse(content) as Record; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const copy = { ...parsed }; - delete copy.model; - return JSON.stringify(copy); - } - return content; - } catch { - return content; - } -}; - -const ensureRecord = (value: unknown): Record => { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - return value as Record; -}; - -const readAutoCompactContextFromRecord = (value: unknown) => { - const compaction = ensureRecord(ensureRecord(value).compaction); - return typeof compaction.auto === "boolean" ? compaction.auto : null; -}; - -const readStoredDefaultModel = () => { - if (typeof window === "undefined") return DEFAULT_MODEL; - try { - const stored = window.localStorage.getItem(MODEL_PREF_KEY); - return parseModelRef(stored) ?? DEFAULT_MODEL; - } catch { - return DEFAULT_MODEL; - } -}; - -export const sessionModelOverridesKey = (workspaceId: string) => - `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; - -export const workspaceModelVariantsKey = (workspaceId: string) => - `${VARIANT_PREF_KEY}.${workspaceId}`; - -export const parseSessionChoiceOverrides = (raw: string | null) => { - if (!raw) return {} as Record; - - try { - const parsed = JSON.parse(raw) as Record; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {} as Record; - } - - const next: Record = {}; - for (const [sessionId, value] of Object.entries(parsed)) { - if (typeof value === "string") { - const model = parseModelRef(value); - if (model) next[sessionId] = { model }; - continue; - } - - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const record = value as Record; - const model = parseStoredModel(record.model ?? record); - const choice = normalizeSessionChoice({ - ...(model ? { model } : {}), - ...(hasOwn(record, "variant") ? { variant: normalizeVariantOverride(record.variant) } : {}), - }); - - if (choice) next[sessionId] = choice; - } - - return next; - } catch { - return {} as Record; - } -}; - -export const serializeSessionChoiceOverrides = ( - overrides: Record, -) => { - const entries = Object.entries(overrides) - .map(([sessionId, choice]) => [sessionId, normalizeSessionChoice(choice)] as const) - .filter((entry): entry is readonly [string, SessionChoiceOverride] => Boolean(entry[1])); - - if (!entries.length) return null; - - const payload: Record = {}; - for (const [sessionId, choice] of entries) { - const next: { model?: string; variant?: string | null } = {}; - if (choice.model) next.model = formatModelRef(choice.model); - if (hasOwn(choice, "variant")) next.variant = choice.variant ?? null; - payload[sessionId] = next; - } - - return JSON.stringify(payload); -}; - -export const parseWorkspaceModelVariants = ( - raw: string | null, - fallbackModel: ModelRef = DEFAULT_MODEL, -) => { - if (!raw || !raw.trim()) return {} as Record; - - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } - - const next: Record = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - const normalized = normalizeVariantOverride(value); - if (normalized) next[key] = normalized; - } - return next; - } catch { - const normalized = normalizeModelBehaviorValue(raw); - return normalized ? { [formatModelRef(fallbackModel)]: normalized } : {}; - } -}; - -export function createModelConfigStore(options: { - client: Accessor; - selectedSessionId: Accessor; - messages: Accessor; - providers: Accessor; - providerDefaults: Accessor>; - providerConnectedIds: Accessor; - selectedWorkspaceId: Accessor; - selectedWorkspaceDisplay: Accessor; - selectedWorkspacePath: Accessor; - openworkServerClient: Accessor; - openworkServerStatus: Accessor; - openworkServerCapabilities: Accessor; - runtimeWorkspaceId: Accessor; - checkDesktopAppRestriction: DesktopAppRestrictionChecker; - focusSessionPromptSoon: () => void; - setError: (value: string | null) => void; - setLastKnownConfigSnapshot: (value: string) => void; - markOpencodeConfigReloadRequired: () => void; -}) { - const initialDefaultModel = readStoredDefaultModel(); - - const [sessionChoiceOverrideById, setSessionChoiceOverrideById] = createSignal< - Record - >({}); - const [sessionModelById, setSessionModelById] = createSignal>({}); - const [pendingSessionChoice, setPendingSessionChoice] = createSignal( - null, - ); - const [sessionModelOverridesReady, setSessionModelOverridesReady] = createSignal(false); - const [workspaceVariantMap, setWorkspaceVariantMap] = createSignal>({}); - - const [defaultModel, setDefaultModel] = createSignal(initialDefaultModel); - const [legacyDefaultModel, setLegacyDefaultModel] = createSignal(initialDefaultModel); - const [defaultModelExplicit, setDefaultModelExplicit] = createSignal(false); - const [workspaceDefaultModelReady, setWorkspaceDefaultModelReady] = createSignal(false); - const [pendingDefaultModelByWorkspace, setPendingDefaultModelByWorkspace] = createSignal< - Record - >({}); - - const [autoCompactContextReady, setAutoCompactContextReady] = createSignal(false); - const [autoCompactContextDirty, setAutoCompactContextDirty] = createSignal(false); - const [autoCompactContextApplied, setAutoCompactContextApplied] = createSignal(true); - const [autoCompactContextSaving, setAutoCompactContextSaving] = createSignal(false); - const [autoCompactContext, setAutoCompactContext] = createSignal(true); - - const [modelPickerOpen, setModelPickerOpen] = createSignal(false); - const [modelPickerTarget, setModelPickerTarget] = createSignal("session"); - const [modelPickerQuery, setModelPickerQuery] = createSignal(""); - const [modelPickerReturnFocusTarget, setModelPickerReturnFocusTarget] = - createSignal("none"); - - const sessionModelState = createMemo(() => ({ - overrides: deriveSessionModelOverrides(sessionChoiceOverrideById()), - resolved: sessionModelById(), - })); - - const setSessionModelState = ( - updater: (current: SessionModelState) => SessionModelState, - ) => { - const next = updater(sessionModelState()); - setSessionChoiceOverrideById((current) => applySessionModelState(current, next)); - setSessionModelById(next.resolved); - return next; - }; - - const setWorkspaceVariant = (ref: ModelRef, value: string | null) => { - const key = formatModelRef(ref); - const normalized = normalizeModelBehaviorValue(value); - - setWorkspaceVariantMap((current) => { - const next = { ...current }; - if (normalized) next[key] = normalized; - else delete next[key]; - return next; - }); - }; - - const setPendingSessionModel = (model: ModelRef) => { - setPendingSessionChoice((current) => - normalizeSessionChoice({ - model, - ...(current && hasOwn(current, "variant") ? { variant: current.variant ?? null } : {}), - }), - ); - }; - - const setPendingSessionVariant = (value: string | null) => { - setPendingSessionChoice((current) => - normalizeSessionChoice({ - ...(current?.model ? { model: current.model } : {}), - variant: normalizeModelBehaviorValue(value), - }), - ); - }; - - const clearPendingSessionChoice = () => setPendingSessionChoice(null); - - const applyPendingSessionChoice = (sessionId: string) => { - const pending = normalizeSessionChoice(pendingSessionChoice()); - if (!pending) return; - - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const next = normalizeSessionChoice({ - ...(existing?.model ? { model: existing.model } : {}), - ...(pending.model ? { model: pending.model } : {}), - ...(hasOwn(existing ?? {}, "variant") ? { variant: existing?.variant ?? null } : {}), - ...(hasOwn(pending, "variant") ? { variant: pending.variant ?? null } : {}), - }); - if (!next) return current; - return { ...current, [sessionId]: next }; - }); - - setPendingSessionChoice(null); - }; - - const setSessionModelOverride = (sessionId: string, model: ModelRef) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const preserveVariant = - existing?.model && - modelEquals(existing.model, model) && - hasOwn(existing, "variant") - ? { variant: existing.variant ?? null } - : hasOwn(existing ?? {}, "variant") && existing?.variant == null - ? { variant: null } - : {}; - - const next = normalizeSessionChoice({ model, ...preserveVariant }); - if (!next) return current; - return { ...current, [sessionId]: next }; - }); - }; - - const clearSessionModelOverride = (sessionId: string) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - if (!existing) return current; - - const next = normalizeSessionChoice( - hasOwn(existing, "variant") ? { variant: existing.variant ?? null } : null, - ); - - const copy = { ...current }; - if (next) copy[sessionId] = next; - else delete copy[sessionId]; - return copy; - }); - }; - - const setSessionVariantOverride = (sessionId: string, value: string | null) => { - setSessionChoiceOverrideById((current) => { - const existing = current[sessionId]; - const next = normalizeSessionChoice({ - ...(existing?.model ? { model: existing.model } : {}), - variant: normalizeModelBehaviorValue(value), - }); - - if (!next) { - const copy = { ...current }; - delete copy[sessionId]; - return copy; - } - - return { ...current, [sessionId]: next }; - }); - }; - - const getWorkspaceVariantFor = (ref: ModelRef) => - workspaceVariantMap()[formatModelRef(ref)] ?? null; - - const isBlockedModelRef = (ref: ModelRef) => - isDesktopModelBlocked({ - model: ref, - checkRestriction: options.checkDesktopAppRestriction, - }); - - const restrictToInstalledModels = () => - options.checkDesktopAppRestriction({ restriction: "disallowNonCloudModels" }); - - const isInstalledProvider = (providerId: string) => - options.providerConnectedIds().some((id) => id.trim() === providerId.trim()); - - const isRestrictedModelRef = (ref: ModelRef) => { - if (isBlockedModelRef(ref)) { - return true; - } - - if (restrictToInstalledModels() && !isInstalledProvider(ref.providerID)) { - return true; - } - - return false; - }; - - const listAllowedModelRefs = () => { - const sortedProviders = options.providers().slice().sort(compareProviders); - const next: ModelRef[] = []; - - for (const provider of sortedProviders) { - const providerId = provider.id?.trim(); - if (!providerId) continue; - if (restrictToInstalledModels() && !isInstalledProvider(providerId)) continue; - - const models = Object.values(provider.models ?? {}) - .filter((model) => model.status !== "deprecated") - .sort((a, b) => { - const aFree = a.cost?.input === 0 && a.cost?.output === 0; - const bFree = b.cost?.input === 0 && b.cost?.output === 0; - if (aFree !== bFree) return aFree ? -1 : 1; - return (a.name ?? a.id).localeCompare(b.name ?? b.id); - }); - - for (const model of models) { - const ref = { providerID: providerId, modelID: model.id }; - if (isRestrictedModelRef(ref)) continue; - next.push(ref); - } - } - - return next; - }; - - const resolveAllowedModelFallback = () => { - if (!isRestrictedModelRef(defaultModel())) { - return defaultModel(); - } - - const allowed = listAllowedModelRefs(); - if (allowed.length > 0) { - return allowed[0]; - } - - return isRestrictedModelRef(DEFAULT_MODEL) ? null : DEFAULT_MODEL; - }; - - const getVariantFor = (ref: ModelRef, sessionId?: string | null) => { - if (sessionId) { - const choice = sessionChoiceOverrideById()[sessionId]; - if (choice && hasOwn(choice, "variant")) { - return choice.variant ?? null; - } - } else { - const pending = pendingSessionChoice(); - if (pending && hasOwn(pending, "variant")) { - return pending.variant ?? null; - } - } - - return getWorkspaceVariantFor(ref); - }; - - const selectedSessionModel = createMemo(() => { - const id = options.selectedSessionId(); - const pendingChoice = pendingSessionChoice(); - if (!id) { - if (pendingChoice?.model && !isRestrictedModelRef(pendingChoice.model)) { - return pendingChoice.model; - } - return defaultModel(); - } - - const override = sessionChoiceOverrideById()[id]?.model; - if (override && !isRestrictedModelRef(override)) return override; - - const known = sessionModelById()[id]; - if (known && !isRestrictedModelRef(known)) return known; - - const fromMessages = lastUserModelFromMessages(options.messages()); - if (fromMessages && !isRestrictedModelRef(fromMessages)) return fromMessages; - - return defaultModel(); - }); - - const modelVariant = createMemo(() => - getVariantFor(selectedSessionModel(), options.selectedSessionId()), - ); - - const resolveCodexReasoningEffort = (modelID: string, variant: string | null) => { - if (!modelID.trim().toLowerCase().includes("codex")) return undefined; - const normalized = normalizeModelBehaviorValue(variant); - if (!normalized || normalized === "none") return undefined; - if (normalized === "minimal") return "low"; - if (normalized === "xhigh" || normalized === "max") return "high"; - if (!["low", "medium", "high"].includes(normalized)) return undefined; - return normalized; - }; - - const findProviderModel = (ref: ModelRef) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID); - return provider?.models?.[ref.modelID] ?? null; - }; - - const sanitizeModelVariantForRef = (ref: ModelRef, value: string | null) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID) ?? null; - const modelInfo = findProviderModel(ref); - if (!modelInfo) return normalizeModelBehaviorValue(value); - return sanitizeModelBehaviorValue(ref.providerID, modelInfo, value, provider?.name); - }; - - const getModelBehaviorCopy = (ref: ModelRef, value: string | null) => { - const provider = options.providers().find((entry) => entry.id === ref.providerID) ?? null; - const modelInfo = findProviderModel(ref); - if (!modelInfo) { - return { - title: t("app.model_behavior_title", currentLocale()), - label: formatGenericBehaviorLabel(value), - description: t("app.model_behavior_desc", currentLocale()), - options: [], - }; - } - return getModelBehaviorSummary(ref.providerID, modelInfo, value, provider?.name); - }; - - const selectedSessionModelLabel = createMemo(() => - formatModelLabel(selectedSessionModel(), options.providers()), - ); - - const sessionModelVariantLabel = createMemo( - () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).label, - ); - - const sessionModelBehaviorOptions = createMemo( - () => getModelBehaviorCopy(selectedSessionModel(), modelVariant()).options, - ); - - const defaultModelLabel = createMemo(() => formatModelLabel(defaultModel(), options.providers())); - const defaultModelRef = createMemo(() => formatModelRef(defaultModel())); - const defaultModelVariantLabel = createMemo( - () => getModelBehaviorCopy(defaultModel(), getWorkspaceVariantFor(defaultModel())).label, - ); - - const modelPickerCurrent = createMemo(() => - modelPickerTarget() === "default" ? defaultModel() : selectedSessionModel(), - ); - - const isHeroModel = (id: string) => id.toLowerCase().includes("gpt-5"); - - const modelOptions = createMemo(() => { - const allProviders = options.providers(); - const defaults = options.providerDefaults(); - const currentDefault = defaultModel(); - - if (!allProviders.length) { - const behavior = getModelBehaviorCopy(DEFAULT_MODEL, getWorkspaceVariantFor(DEFAULT_MODEL)); - return [ - { - providerID: DEFAULT_MODEL.providerID, - modelID: DEFAULT_MODEL.modelID, - title: DEFAULT_MODEL.modelID, - description: DEFAULT_MODEL.providerID, - footer: t("settings.model_fallback", currentLocale()), - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue: normalizeModelBehaviorValue(getWorkspaceVariantFor(DEFAULT_MODEL)), - behaviorOptions: behavior.options, - isFree: true, - isConnected: false, - }, - ]; - } - - const sortedProviders = allProviders.slice().sort(compareProviders); - const next: ModelOption[] = []; - - for (const provider of sortedProviders) { - const defaultModelID = defaults[provider.id]; - const isConnected = options.providerConnectedIds().includes(provider.id); - if (restrictToInstalledModels() && !isConnected) continue; - const models = Object.values(provider.models ?? {}).filter((m) => m.status !== "deprecated"); - - models.sort((a, b) => { - const aFree = a.cost?.input === 0 && a.cost?.output === 0; - const bFree = b.cost?.input === 0 && b.cost?.output === 0; - if (aFree !== bFree) return aFree ? -1 : 1; - return (a.name ?? a.id).localeCompare(b.name ?? b.id); - }); - - for (const model of models) { - const isFree = model.cost?.input === 0 && model.cost?.output === 0; - const isDefault = - provider.id === currentDefault.providerID && model.id === currentDefault.modelID; - const ref = { providerID: provider.id, modelID: model.id }; - if (isRestrictedModelRef(ref)) continue; - const activeVariant = - modelPickerTarget() === "session" && modelEquals(ref, selectedSessionModel()) - ? modelVariant() - : getWorkspaceVariantFor(ref); - const behavior = getModelBehaviorSummary(provider.id, model, activeVariant, provider.name); - const behaviorValue = sanitizeModelBehaviorValue(provider.id, model, activeVariant, provider.name); - const footerBits: string[] = []; - if (defaultModelID === model.id || isDefault) { - footerBits.push(t("settings.model_default", currentLocale())); - } - if (model.capabilities?.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale())); - - next.push({ - providerID: provider.id, - modelID: model.id, - title: model.name ?? model.id, - description: provider.name, - footer: footerBits.length ? footerBits.slice(0, 2).join(" · ") : undefined, - behaviorTitle: behavior.title, - behaviorLabel: behavior.label, - behaviorDescription: behavior.description, - behaviorValue, - behaviorOptions: behavior.options, - disabled: !isConnected, - isFree, - isConnected, - isRecommended: isHeroModel(model.id), - }); - } - } - - next.sort((a, b) => { - if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; - if (a.isFree !== b.isFree) return a.isFree ? -1 : 1; - const providerRankDiff = providerPriorityRank(a.providerID) - providerPriorityRank(b.providerID); - if (providerRankDiff !== 0) return providerRankDiff; - return a.title.localeCompare(b.title); - }); - - return next; - }); - - const filteredModelOptions = createMemo(() => { - const q = modelPickerQuery().trim().toLowerCase(); - const optionsList = modelOptions(); - if (!q) return optionsList; - - return optionsList.filter((opt) => { - const haystack = [ - opt.title, - opt.description ?? "", - opt.footer ?? "", - opt.behaviorTitle, - opt.behaviorLabel, - opt.behaviorDescription, - `${opt.providerID}/${opt.modelID}`, - opt.isConnected ? "connected" : "disconnected", - opt.isFree ? "free" : "paid", - ] - .join(" ") - .toLowerCase(); - return haystack.includes(q); - }); - }); - - const setPendingDefaultModelForWorkspace = (workspaceId: string, model: ModelRef | null) => { - const id = workspaceId.trim(); - if (!id) return; - setPendingDefaultModelByWorkspace((current) => { - const next = { ...current }; - if (model) { - next[id] = formatModelRef(model); - } else { - delete next[id]; - } - return next; - }); - }; - - const pendingDefaultModelForWorkspace = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return null; - return pendingDefaultModelByWorkspace()[id] ?? null; - }; - - const applyDefaultModelChoice = (next: ModelRef) => { - const workspaceId = options.selectedWorkspaceId().trim(); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, next); - } - setDefaultModelExplicit(true); - setDefaultModel(next); - setLegacyDefaultModel(next); - }; - - const closeModelPicker = (opts?: { restorePromptFocus?: boolean }) => { - const shouldFocusPrompt = - opts?.restorePromptFocus ?? modelPickerReturnFocusTarget() === "composer"; - setModelPickerOpen(false); - setModelPickerReturnFocusTarget("none"); - if (shouldFocusPrompt) { - options.focusSessionPromptSoon(); - } - }; - - const openSessionModelPicker = (opts?: { - returnFocusTarget?: PromptFocusReturnTarget; - }) => { - setModelPickerTarget("session"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget(opts?.returnFocusTarget ?? "composer"); - setModelPickerOpen(true); - }; - - const openDefaultModelPicker = () => { - setModelPickerTarget("default"); - setModelPickerQuery(""); - setModelPickerReturnFocusTarget("none"); - setModelPickerOpen(true); - }; - - const applyModelSelection = (next: ModelRef) => { - const target = modelPickerTarget(); - const restorePromptFocus = target === "session"; - - if (target === "default") { - applyDefaultModelChoice(next); - closeModelPicker({ restorePromptFocus: false }); - return; - } - - const id = options.selectedSessionId(); - if (!id) { - setPendingSessionModel(next); - closeModelPicker({ restorePromptFocus }); - return; - } - - setSessionModelOverride(id, next); - closeModelPicker({ restorePromptFocus }); - }; - - const setModelPickerBehavior = (model: ModelRef, value: string | null) => { - const nextValue = sanitizeModelVariantForRef(model, value); - if (modelPickerTarget() === "default") { - setWorkspaceVariant(model, nextValue); - return; - } - - const sessionId = options.selectedSessionId(); - if (sessionId) { - setSessionVariantOverride(sessionId, nextValue); - return; - } - - setPendingSessionVariant(nextValue); - }; - - const setSessionModelVariant = (value: string | null) => { - const sessionId = options.selectedSessionId(); - const nextValue = sanitizeModelVariantForRef(selectedSessionModel(), value); - if (sessionId) { - setSessionVariantOverride(sessionId, nextValue); - return; - } - setPendingSessionVariant(nextValue); - }; - - const toggleAutoCompactContext = () => { - if (autoCompactContextSaving()) return; - setAutoCompactContext((value) => !value); - setAutoCompactContextDirty(true); - }; - - const resetAppDefaults = () => { - if (typeof window !== "undefined") { - try { - const sessionOverridePrefix = `${SESSION_MODEL_PREF_KEY}.`; - const workspaceVariantPrefix = `${VARIANT_PREF_KEY}.`; - const keysToRemove: string[] = []; - for (let index = 0; index < window.localStorage.length; index += 1) { - const key = window.localStorage.key(index); - if (!key) continue; - if ( - key.startsWith(sessionOverridePrefix) || - key.startsWith(workspaceVariantPrefix) || - key === VARIANT_PREF_KEY - ) { - keysToRemove.push(key); - } - } - for (const key of keysToRemove) { - window.localStorage.removeItem(key); - } - } catch { - // ignore - } - } - - setDefaultModel(DEFAULT_MODEL); - setLegacyDefaultModel(DEFAULT_MODEL); - setDefaultModelExplicit(false); - setWorkspaceDefaultModelReady(false); - setPendingDefaultModelByWorkspace({}); - setAutoCompactContext(false); - setAutoCompactContextApplied(false); - setAutoCompactContextDirty(false); - setAutoCompactContextReady(false); - setAutoCompactContextSaving(false); - clearPendingSessionChoice(); - setSessionChoiceOverrideById({}); - setSessionModelById({}); - setWorkspaceVariantMap({}); - closeModelPicker({ restorePromptFocus: false }); - }; - - const reconcileRestrictedModels = () => { - const isRestrictedChoice = (model: ModelRef | null | undefined) => { - if (!model) return false; - return isRestrictedModelRef(model); - }; - - const fallback = resolveAllowedModelFallback(); - - if (isRestrictedChoice(defaultModel()) && fallback && !modelEquals(defaultModel(), fallback)) { - applyDefaultModelChoice(fallback); - } - - setPendingSessionChoice((current) => { - if (!current?.model || !isRestrictedChoice(current.model)) { - return current; - } - - return hasOwn(current, "variant") - ? { variant: current.variant ?? null } - : null; - }); - - setSessionChoiceOverrideById((current) => { - const next: Record = {}; - let changed = false; - - for (const [sessionId, choice] of Object.entries(current)) { - if (!choice.model || !isRestrictedChoice(choice.model)) { - next[sessionId] = choice; - continue; - } - - changed = true; - const stripped = normalizeSessionChoice( - hasOwn(choice, "variant") ? { variant: choice.variant ?? null } : null, - ); - if (stripped) { - next[sessionId] = stripped; - } - } - - return changed ? next : current; - }); - - setSessionModelById((current) => { - const next = Object.fromEntries( - Object.entries(current).filter(([, model]) => !isRestrictedChoice(model)), - ); - return Object.keys(next).length === Object.keys(current).length ? current : next; - }); - - setWorkspaceVariantMap((current) => { - const next = Object.fromEntries( - Object.entries(current).filter(([ref]) => { - const parsed = parseModelRef(ref); - return !parsed || !isRestrictedChoice(parsed); - }), - ); - return Object.keys(next).length === Object.keys(current).length ? current : next; - }); - }; - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - setSessionModelOverridesReady(false); - const raw = window.localStorage.getItem(sessionModelOverridesKey(workspaceId)); - setSessionChoiceOverrideById(parseSessionChoiceOverrides(raw)); - setSessionModelOverridesReady(true); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - if (!sessionModelOverridesReady()) return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - const payload = serializeSessionChoiceOverrides(sessionChoiceOverrideById()); - try { - if (payload) { - window.localStorage.setItem(sessionModelOverridesKey(workspaceId), payload); - } else { - window.localStorage.removeItem(sessionModelOverridesKey(workspaceId)); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId().trim(); - if (!workspaceId) { - setWorkspaceVariantMap({}); - return; - } - - const scopedRaw = window.localStorage.getItem(workspaceModelVariantsKey(workspaceId)); - const legacyRaw = scopedRaw == null ? window.localStorage.getItem(VARIANT_PREF_KEY) : null; - setWorkspaceVariantMap(parseWorkspaceModelVariants(scopedRaw ?? legacyRaw, defaultModel())); - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId().trim(); - if (!workspaceId) return; - - try { - const map = workspaceVariantMap(); - const key = workspaceModelVariantsKey(workspaceId); - if (Object.keys(map).length > 0) { - window.localStorage.setItem(key, JSON.stringify(map)); - } else { - window.localStorage.removeItem(key); - } - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(MODEL_PREF_KEY, formatModelRef(defaultModel())); - } catch { - // ignore - } - }); - - createEffect(() => { - if (typeof window === "undefined") return; - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) return; - - setWorkspaceDefaultModelReady(false); - const workspace = options.selectedWorkspaceDisplay(); - const workspaceRoot = options.selectedWorkspacePath().trim(); - const activeClient = options.client(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.read; - - let cancelled = false; - - const applyDefault = async () => { - let configDefault: ModelRef | null = null; - let configFileContent: string | null = null; - - if (workspace.workspaceType === "local" && workspaceRoot) { - if (canUseOpenworkServer) { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const model = typeof config.opencode?.model === "string" ? config.opencode.model : null; - configDefault = parseModelRef(model); - } catch { - // ignore - } - } else if (isTauriRuntime()) { - try { - const configFile = await readOpencodeConfig("project", workspaceRoot); - configFileContent = configFile.content; - configDefault = parseDefaultModelFromConfig(configFile.content); - } catch { - // ignore - } - } - } else if (activeClient) { - try { - const config = await activeClient.config.get({ directory: workspaceRoot || undefined }); - const payload = "data" in config ? config.data : config; - if (typeof payload?.model === "string") { - configDefault = parseModelRef(payload.model); - } - } catch { - // ignore - } - } - - const pendingModelRef = pendingDefaultModelForWorkspace(workspaceId); - const loadedModelRef = configDefault ? formatModelRef(configDefault) : null; - - if (pendingModelRef && pendingModelRef !== loadedModelRef) { - if (workspace.workspaceType === "local" && workspaceRoot) { - options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); - } - - if (!cancelled) { - setWorkspaceDefaultModelReady(true); - } - return; - } - - if (pendingModelRef && loadedModelRef === pendingModelRef) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - - setDefaultModelExplicit(Boolean(configDefault)); - const nextDefault = configDefault ?? legacyDefaultModel(); - const currentDefault = defaultModel(); - if (nextDefault && !modelEquals(currentDefault, nextDefault)) { - setDefaultModel(nextDefault); - } - const currentLegacyDefault = legacyDefaultModel(); - if (nextDefault && !modelEquals(currentLegacyDefault, nextDefault)) { - setLegacyDefaultModel(nextDefault); - } - - if (workspace.workspaceType === "local" && workspaceRoot) { - options.setLastKnownConfigSnapshot(getConfigSnapshot(configFileContent)); - } - - if (!cancelled) { - setWorkspaceDefaultModelReady(true); - } - }; - - void applyDefault(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!workspaceDefaultModelReady()) return; - if (!isTauriRuntime()) return; - if (!defaultModelExplicit()) return; - - const workspace = options.selectedWorkspaceDisplay(); - const workspaceId = options.selectedWorkspaceId().trim(); - if (workspace.workspaceType !== "local") return; - - const root = options.selectedWorkspacePath().trim(); - if (!root) return; - const nextModel = defaultModel(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.write; - let cancelled = false; - - const writeConfig = async () => { - try { - if (canUseOpenworkServer) { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const currentModel = - typeof config.opencode?.model === "string" ? parseModelRef(config.opencode.model) : null; - if (currentModel && modelEquals(currentModel, nextModel)) { - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - return; - } - - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { model: formatModelRef(nextModel) }, - }); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - options.markOpencodeConfigReloadRequired(); - return; - } - - const configFile = await readOpencodeConfig("project", root); - const existingModel = parseDefaultModelFromConfig(configFile.content); - if (existingModel && modelEquals(existingModel, nextModel)) { - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - return; - } - - const content = formatConfigWithDefaultModel(configFile.content, nextModel); - const result = await writeOpencodeConfig("project", root, content); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || t("app.error_update_opencode_json", currentLocale())); - } - options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); - if (workspaceId) { - setPendingDefaultModelForWorkspace(workspaceId, null); - } - options.markOpencodeConfigReloadRequired(); - } catch (error) { - if (cancelled) return; - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } - }; - - void writeConfig(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const workspaceId = options.selectedWorkspaceId(); - if (!workspaceId) { - setAutoCompactContext(true); - setAutoCompactContextApplied(true); - setAutoCompactContextDirty(false); - setAutoCompactContextReady(false); - setAutoCompactContextSaving(false); - return; - } - - const workspace = options.selectedWorkspaceDisplay(); - const root = options.selectedWorkspacePath().trim(); - const activeClient = options.client(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.read; - - let cancelled = false; - setAutoCompactContextReady(false); - setAutoCompactContextDirty(false); - - const loadAutoCompactContext = async () => { - let nextValue = true; - - if (canUseOpenworkServer) { - try { - const config = await openworkClient.getConfig(openworkWorkspaceId); - nextValue = readAutoCompactContextFromRecord(config.opencode) ?? true; - } catch { - // ignore - } - } else if (workspace.workspaceType === "local" && root && isTauriRuntime()) { - try { - const configFile = await readOpencodeConfig("project", root); - nextValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; - } catch { - // ignore - } - } else if (activeClient) { - try { - const config = await activeClient.config.get({ directory: root || undefined }); - const payload = "data" in config ? config.data : config; - nextValue = readAutoCompactContextFromRecord(payload) ?? true; - } catch { - // ignore - } - } - - if (cancelled) return; - setAutoCompactContext(nextValue); - setAutoCompactContextApplied(nextValue); - setAutoCompactContextReady(true); - }; - - void loadAutoCompactContext(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - if (!autoCompactContextReady()) return; - if (!autoCompactContextDirty()) return; - - const nextValue = autoCompactContext(); - const appliedValue = autoCompactContextApplied(); - const workspace = options.selectedWorkspaceDisplay(); - const root = options.selectedWorkspacePath().trim(); - const openworkClient = options.openworkServerClient(); - const openworkWorkspaceId = options.runtimeWorkspaceId(); - const openworkCapabilities = options.openworkServerCapabilities(); - const canUseOpenworkServer = - options.openworkServerStatus() === "connected" && - openworkClient && - openworkWorkspaceId && - openworkCapabilities?.config?.write; - - let cancelled = false; - setAutoCompactContextSaving(true); - - const persistAutoCompactContext = async () => { - try { - if (canUseOpenworkServer) { - const config = await openworkClient.getConfig(openworkWorkspaceId); - const currentValue = readAutoCompactContextFromRecord(config.opencode) ?? true; - if (currentValue !== nextValue) { - await openworkClient.patchConfig(openworkWorkspaceId, { - opencode: { - compaction: { - auto: nextValue, - }, - }, - }); - options.markOpencodeConfigReloadRequired(); - } - if (cancelled) return; - setAutoCompactContextApplied(nextValue); - setAutoCompactContextDirty(false); - return; - } - - if (workspace.workspaceType !== "local" || !root || !isTauriRuntime()) { - throw new Error( - t("app.error_auto_compact_scope", currentLocale()), - ); - } - - const configFile = await readOpencodeConfig("project", root); - const currentValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; - if (currentValue !== nextValue) { - const content = formatConfigWithAutoCompactContext(configFile.content, nextValue); - const result = await writeOpencodeConfig("project", root, content); - if (!result.ok) { - throw new Error(result.stderr || result.stdout || t("app.error_update_opencode_json", currentLocale())); - } - options.setLastKnownConfigSnapshot(getConfigSnapshot(content)); - options.markOpencodeConfigReloadRequired(); - } - - if (cancelled) return; - setAutoCompactContextApplied(nextValue); - setAutoCompactContextDirty(false); - } catch (error) { - if (cancelled) return; - setAutoCompactContext(appliedValue); - setAutoCompactContextDirty(false); - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } finally { - setAutoCompactContextSaving(false); - } - }; - - void persistAutoCompactContext(); - - onCleanup(() => { - cancelled = true; - }); - }); - - return { - sessionChoiceOverrideById, - setSessionChoiceOverrideById, - sessionModelById, - setSessionModelById, - sessionModelState, - setSessionModelState, - pendingSessionChoice, - setPendingSessionModel, - setPendingSessionVariant, - clearPendingSessionChoice, - applyPendingSessionChoice, - sessionModelOverridesReady, - setSessionModelOverridesReady, - workspaceVariantMap, - setWorkspaceVariantMap, - setWorkspaceVariant, - setSessionModelOverride, - clearSessionModelOverride, - setSessionVariantOverride, - getWorkspaceVariantFor, - getVariantFor, - defaultModel, - selectedSessionModel, - selectedSessionModelLabel, - defaultModelLabel, - defaultModelRef, - defaultModelVariantLabel, - modelVariant, - sessionModelVariantLabel, - sessionModelBehaviorOptions, - setSessionModelVariant, - sanitizeModelVariantForRef, - resolveCodexReasoningEffort, - modelPickerOpen, - modelPickerQuery, - setModelPickerQuery, - modelPickerTarget, - modelPickerCurrent, - modelOptions, - filteredModelOptions, - openSessionModelPicker, - openDefaultModelPicker, - closeModelPicker, - applyModelSelection, - setModelPickerBehavior, - reconcileRestrictedModels, - autoCompactContext, - toggleAutoCompactContext, - autoCompactContextSaving, - resetAppDefaults, - }; -} diff --git a/apps/app/src/app/context/platform.tsx b/apps/app/src/app/context/platform.tsx deleted file mode 100644 index 78df67a2..00000000 --- a/apps/app/src/app/context/platform.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -export type SyncStorage = { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -}; - -export type AsyncStorage = { - getItem(key: string): Promise; - setItem(key: string, value: string): Promise; - removeItem(key: string): Promise; -}; - -export type Platform = { - platform: "web" | "desktop"; - os?: "macos" | "windows" | "linux"; - version?: string; - openLink(url: string): void; - restart(): Promise; - notify(title: string, description?: string, href?: string): Promise; - storage?: (name?: string) => SyncStorage | AsyncStorage; - checkUpdate?: () => Promise<{ updateAvailable: boolean; version?: string }>; - update?: () => Promise; - fetch?: typeof fetch; - getDefaultServerUrl?: () => Promise; - setDefaultServerUrl?: (url: string | null) => Promise; -}; - -const PlatformContext = createContext(undefined); - -export function PlatformProvider(props: ParentProps & { value: Platform }) { - return ( - - {props.children} - - ); -} - -export function usePlatform() { - const context = useContext(PlatformContext); - if (!context) { - throw new Error("Platform context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/providers/index.ts b/apps/app/src/app/context/providers/index.ts deleted file mode 100644 index c5e52092..00000000 --- a/apps/app/src/app/context/providers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createProvidersStore } from "./store"; -export type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; -export { default as ProviderAuthModal } from "./provider-auth-modal"; diff --git a/apps/app/src/app/context/providers/provider-auth-modal.tsx b/apps/app/src/app/context/providers/provider-auth-modal.tsx deleted file mode 100644 index b2f14125..00000000 --- a/apps/app/src/app/context/providers/provider-auth-modal.tsx +++ /dev/null @@ -1,1056 +0,0 @@ -import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid"; -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; - -import { isTauriRuntime } from "../../utils"; -import { compareProviders } from "../../utils/providers"; -import Button from "../../components/button"; -import ProviderIcon from "../../components/provider-icon"; -import TextInput from "../../components/text-input"; -import { useDesktopConfig } from "../../cloud/desktop-config-provider"; -import type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; - -type ProviderAuthEntry = { - id: string; - name: string; - methods: ProviderAuthMethod[]; - connected: boolean; - env: string[]; -}; - -type ProviderOAuthSession = ProviderOAuthStartResult & { - providerId: string; - methodLabel: string; -}; - -const PROVIDER_LABELS: Record = { - opencode: "OpenCode", - openai: "OpenAI", - anthropic: "Anthropic", - google: "Google", - openrouter: "OpenRouter", -}; - -export type ProviderAuthModalProps = { - open: boolean; - loading: boolean; - submitting: boolean; - error: string | null; - restricted?: boolean; - restrictedMessage?: string | null; - preferredProviderId?: string | null; - workerType?: "local" | "remote"; - providers: ProviderAuthProvider[]; - connectedProviderIds: string[]; - authMethods: Record; - onSelect: (providerId: string, methodIndex?: number) => Promise; - onSubmitApiKey: (providerId: string, apiKey: string) => Promise; - onConnectCloudProvider: (cloudProviderId: string) => Promise; - onSubmitOAuth: ( - providerId: string, - methodIndex: number, - code?: string - ) => Promise<{ connected: boolean; pending?: boolean; message?: string }>; - onRefreshProviders?: () => Promise; - onClose: () => void; -}; - -export default function ProviderAuthModal(props: ProviderAuthModalProps) { - const desktopConfig = useDesktopConfig(); - const workerType = createMemo(() => (props.workerType === "remote" ? "remote" : "local")); - const isRemoteWorker = createMemo(() => workerType() === "remote"); - const restricted = createMemo(() => - props.restricted ?? desktopConfig.checkRestriction({ restriction: "disallowNonCloudModels" }), - ); - const restrictionMessage = createMemo( - () => - props.restrictedMessage?.trim() || - "Your administrator has restricted which providers and models are allowed. Please reach out to them to add new providers and models.", - ); - - const formatProviderName = (id: string, fallback?: string) => { - const named = fallback?.trim(); - if (named) return named; - - const normalized = id.trim(); - const mapped = PROVIDER_LABELS[normalized.toLowerCase()]; - if (mapped) return mapped; - - const cleaned = normalized.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); - if (!cleaned) return id; - - return cleaned - .split(" ") - .filter(Boolean) - .map((word) => { - if (/\d/.test(word) || word.length <= 3) { - return word.toUpperCase(); - } - const lower = word.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join(" "); - }; - - const isOpenAiHeadlessMethod = (method: ProviderAuthMethod) => { - const label = method.label.toLowerCase(); - return method.type === "oauth" && (label.includes("headless") || label.includes("device")); - }; - - const isOpenAiProvider = (id: string, fallbackName?: string) => { - const normalizedId = id.trim().toLowerCase(); - const normalizedName = fallbackName?.trim().toLowerCase() ?? ""; - return normalizedId === "openai" || normalizedName.includes("openai"); - }; - - // TODO: remove once we upgrade to opencode 1.3.0 — the Claude Pro/Max OAuth - // method is dropped upstream there, so this client-side filter is no longer needed. - const isAnthropicProvider = (id: string, fallbackName?: string) => { - const normalizedId = id.trim().toLowerCase(); - const normalizedName = fallbackName?.trim().toLowerCase() ?? ""; - return normalizedId === "anthropic" || normalizedName.includes("anthropic"); - }; - - const isClaudeProMaxMethod = (method: ProviderAuthMethod) => { - const label = method.label.toLowerCase(); - return method.type === "oauth" && (label.includes("pro/max") || label.includes("create an api key")); - }; - - const entries = createMemo(() => { - const methods = props.authMethods ?? {}; - const connected = new Set(props.connectedProviderIds ?? []); - const providers = props.providers ?? []; - - return Object.keys(methods) - .map((id): ProviderAuthEntry => { - const provider = providers.find((item) => item.id === id); - const entryMethods = (methods[id] ?? []).filter((method) => { - if (isAnthropicProvider(id, provider?.name) && isClaudeProMaxMethod(method)) { - return false; - } - if (!isOpenAiProvider(id, provider?.name)) return true; - if (method.type !== "oauth") return true; - if (isRemoteWorker()) return isOpenAiHeadlessMethod(method); - return !isOpenAiHeadlessMethod(method); - }); - return { - id, - name: formatProviderName(id, provider?.name), - methods: entryMethods, - connected: connected.has(id), - env: Array.isArray(provider?.env) ? provider.env : [], - }; - }) - .filter((entry) => entry.methods.length > 0) - .sort(compareProviders); - }); - - const methodLabel = (method: ProviderAuthMethod) => - method.label || (method.type === "oauth" ? "OAuth" : "API key"); - - const actionDisabled = () => props.loading || props.submitting; - - const [view, setView] = createSignal<"list" | "method" | "api" | "cloud" | "oauth-code" | "oauth-auto">("list"); - const [selectedProviderId, setSelectedProviderId] = createSignal(null); - const [selectedCloudMethod, setSelectedCloudMethod] = createSignal(null); - const [apiKeyInput, setApiKeyInput] = createSignal(""); - const [oauthCodeInput, setOauthCodeInput] = createSignal(""); - const [oauthSession, setOauthSession] = createSignal(null); - const [searchQuery, setSearchQuery] = createSignal(""); - const [activeEntryIndex, setActiveEntryIndex] = createSignal(0); - const [localError, setLocalError] = createSignal(null); - const [pollingBusy, setPollingBusy] = createSignal(false); - const [oauthAutoBusy, setOauthAutoBusy] = createSignal(false); - const [oauthCodeCopied, setOauthCodeCopied] = createSignal(false); - const [oauthBrowserOpened, setOauthBrowserOpened] = createSignal(false); - const [autoOpenedPreferredProviderId, setAutoOpenedPreferredProviderId] = createSignal(null); - let searchInputEl: HTMLInputElement | undefined; - let providerPoll: number | null = null; - let oauthAutoPoll: number | null = null; - let oauthCodeCopiedReset: number | null = null; - - const selectedEntry = createMemo(() => - entries().find((entry) => entry.id === selectedProviderId()) ?? null, - ); - - const resolvedView = createMemo(() => (selectedEntry() ? view() : "list")); - const errorMessage = createMemo(() => localError() ?? props.error); - - const filteredEntries = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - if (!query) return entries(); - return entries().filter((entry) => { - const methodText = entry.methods.map((method) => methodLabel(method)).join(" "); - return `${entry.name} ${entry.id} ${methodText}`.toLowerCase().includes(query); - }); - }); - - const oauthInstructions = createMemo(() => oauthSession()?.authorization.instructions?.trim() ?? ""); - const isOpenAiHeadlessSession = createMemo(() => { - const session = oauthSession(); - if (!session) return false; - return session.providerId === "openai" && session.methodLabel.toLowerCase().includes("headless"); - }); - const shouldStartOauthAutoPolling = createMemo(() => { - if (!props.open || resolvedView() !== "oauth-auto" || !oauthSession()) { - return false; - } - if (!isOpenAiHeadlessSession()) return true; - return oauthBrowserOpened(); - }); - - const oauthDisplayCode = createMemo(() => { - const instructions = oauthInstructions(); - if (!instructions) return ""; - const matched = instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0]; - if (matched) return matched; - if (instructions.includes(":")) { - return instructions.split(":").slice(1).join(":").trim(); - } - return instructions; - }); - - const resetState = () => { - if (oauthCodeCopiedReset !== null && typeof window !== "undefined") { - window.clearTimeout(oauthCodeCopiedReset); - oauthCodeCopiedReset = null; - } - setView("list"); - setSelectedProviderId(null); - setSelectedCloudMethod(null); - setApiKeyInput(""); - setOauthCodeInput(""); - setOauthSession(null); - setSearchQuery(""); - setActiveEntryIndex(0); - setLocalError(null); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - }; - - createEffect(() => { - if (!props.open) { - setAutoOpenedPreferredProviderId(null); - resetState(); - } - }); - - createEffect(() => { - if (!props.open || resolvedView() !== "list") return; - const total = filteredEntries().length; - if (total <= 0) { - setActiveEntryIndex(0); - return; - } - setActiveEntryIndex((current) => Math.max(0, Math.min(current, total - 1))); - }); - - createEffect(() => { - if (!props.open || resolvedView() !== "list") return; - queueMicrotask(() => { - searchInputEl?.focus(); - }); - }); - - createEffect(() => { - if (!props.open || props.loading || resolvedView() !== "list") return; - - const preferredId = props.preferredProviderId?.trim().toLowerCase() ?? ""; - if (!preferredId || autoOpenedPreferredProviderId() === preferredId) return; - - const entry = entries().find((item) => item.id.trim().toLowerCase() === preferredId); - if (!entry) return; - - setAutoOpenedPreferredProviderId(preferredId); - queueMicrotask(() => { - handleEntrySelect(entry); - }); - }); - - const handleClose = () => { - void props.onRefreshProviders?.(); - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - resetState(); - props.onClose(); - }; - - onCleanup(() => { - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - if (oauthCodeCopiedReset !== null) { - window.clearTimeout(oauthCodeCopiedReset); - oauthCodeCopiedReset = null; - } - }); - - const isOauthView = () => resolvedView() === "oauth-code" || resolvedView() === "oauth-auto"; - const activeProviderId = () => oauthSession()?.providerId ?? selectedProviderId(); - - const isActiveProviderConnected = () => { - const id = activeProviderId(); - if (!id) return false; - return (props.connectedProviderIds ?? []).includes(id); - }; - - const pollProviders = async () => { - const id = activeProviderId(); - if (!id) return; - if (pollingBusy()) return; - setPollingBusy(true); - try { - await props.onRefreshProviders?.(); - } finally { - setPollingBusy(false); - } - if (isActiveProviderConnected()) { - handleClose(); - } - }; - - const startProviderPolling = () => { - if (typeof window === "undefined") return; - if (providerPoll !== null) return; - void pollProviders(); - providerPoll = window.setInterval(() => { - void pollProviders(); - }, 2000); - }; - - const stopProviderPolling = () => { - if (providerPoll !== null) { - window.clearInterval(providerPoll); - providerPoll = null; - } - }; - - createEffect(() => { - if (!props.open || !isOauthView()) { - stopProviderPolling(); - return; - } - if (isActiveProviderConnected()) { - handleClose(); - return; - } - startProviderPolling(); - }); - - createEffect(() => { - if (!shouldStartOauthAutoPolling()) { - stopOauthAutoPolling(); - return; - } - startOauthAutoPolling(); - }); - - const openOauthUrl = async (url: string) => { - if (!url) return; - if (isTauriRuntime()) { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl(url); - setOauthBrowserOpened(true); - return; - } - window.open(url, "_blank", "noopener,noreferrer"); - setOauthBrowserOpened(true); - }; - - const copyOauthDisplayCode = async () => { - const code = oauthDisplayCode().trim(); - if (!code) return; - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - setLocalError("Clipboard is unavailable in this environment."); - return; - } - await navigator.clipboard.writeText(code); - setOauthCodeCopied(true); - if (typeof window === "undefined") return; - if (oauthCodeCopiedReset !== null) { - window.clearTimeout(oauthCodeCopiedReset); - } - oauthCodeCopiedReset = window.setTimeout(() => { - setOauthCodeCopied(false); - oauthCodeCopiedReset = null; - }, 2000); - }; - - const submitOauth = async (providerId: string, methodIndex: number, code?: string) => { - const trimmedCode = code?.trim(); - setLocalError(null); - try { - return await props.onSubmitOAuth(providerId, methodIndex, trimmedCode || undefined); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to complete OAuth"; - setLocalError(message); - throw error instanceof Error ? error : new Error(message); - } - }; - - const stopOauthAutoPolling = () => { - if (oauthAutoPoll !== null) { - window.clearInterval(oauthAutoPoll); - oauthAutoPoll = null; - } - }; - - const attemptOauthAutoCompletion = async () => { - const session = oauthSession(); - if (!session || oauthAutoBusy()) return; - setOauthAutoBusy(true); - try { - const result = await submitOauth(session.providerId, session.methodIndex); - if (result?.connected) { - stopOauthAutoPolling(); - } - } finally { - setOauthAutoBusy(false); - } - }; - - const startOauthAutoPolling = () => { - if (typeof window === "undefined") return; - if (oauthAutoPoll !== null) return; - void attemptOauthAutoCompletion(); - oauthAutoPoll = window.setInterval(() => { - void attemptOauthAutoCompletion(); - }, 2000); - }; - - const startOauth = async (entry: ProviderAuthEntry, methodIndex?: number) => { - if (actionDisabled()) return; - if (!Number.isInteger(methodIndex) || methodIndex === undefined) { - setLocalError(`No OAuth flow available for ${entry.name}.`); - return; - } - setLocalError(null); - setOauthCodeInput(""); - setOauthSession(null); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - try { - const started = await props.onSelect(entry.id, methodIndex); - const selectedMethod = entry.methods.find((method) => method.methodIndex === methodIndex); - if (!selectedMethod) { - throw new Error(`Selected auth method is unavailable for ${entry.name}.`); - } - const nextSession: ProviderOAuthSession = { - providerId: entry.id, - methodIndex: started.methodIndex, - methodLabel: selectedMethod.label, - authorization: started.authorization, - }; - setOauthSession(nextSession); - - if (started.authorization.method === "code") { - await openOauthUrl(started.authorization.url); - setView("oauth-code"); - return; - } - - if (!isOpenAiHeadlessMethod(selectedMethod)) { - await openOauthUrl(started.authorization.url); - } - - setView("oauth-auto"); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to start OAuth"; - setLocalError(message); - } - }; - - const handleEntrySelect = (entry: ProviderAuthEntry) => { - if (actionDisabled()) return; - setLocalError(null); - setSelectedProviderId(entry.id); - - if (entry.methods.length === 1) { - void handleMethodSelect(entry.methods[0]); - return; - } - - if (entry.methods.length > 1) { - setView("method"); - return; - } - - setLocalError(`No authentication methods available for ${entry.name}.`); - }; - - const handleMethodSelect = async (method: ProviderAuthMethod) => { - const entry = selectedEntry(); - if (!entry || actionDisabled()) return; - setLocalError(null); - setSelectedCloudMethod(null); - - if (method.type === "oauth") { - await startOauth(entry, method.methodIndex); - return; - } - - if (method.type === "cloud") { - setSelectedCloudMethod(method); - setView("cloud"); - return; - } - - setView("api"); - }; - - const handleApiSubmit = async () => { - const entry = selectedEntry(); - if (!entry || actionDisabled()) return; - - const trimmed = apiKeyInput().trim(); - if (!trimmed) { - setLocalError("API key is required."); - return; - } - - setLocalError(null); - try { - await props.onSubmitApiKey(entry.id, trimmed); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to save API key"; - setLocalError(message); - } - }; - - const handleCloudSubmit = async () => { - const method = selectedCloudMethod(); - if (!method?.cloudProviderId || actionDisabled()) return; - - setLocalError(null); - try { - await props.onConnectCloudProvider(method.cloudProviderId); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to connect organization provider"; - setLocalError(message); - } - }; - - const handleOauthCodeSubmit = async () => { - const entry = selectedEntry(); - const session = oauthSession(); - if (!entry || !session || actionDisabled()) return; - - const trimmed = oauthCodeInput().trim(); - if (!trimmed) { - setLocalError("Authorization code is required."); - return; - } - - await submitOauth(entry.id, session.methodIndex, trimmed); - }; - - const handleBack = () => { - if (resolvedView() === "oauth-code" || resolvedView() === "oauth-auto") { - if ((selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - } else { - setView("list"); - } - setOauthSession(null); - setOauthCodeInput(""); - setOauthCodeCopied(false); - setOauthBrowserOpened(false); - setLocalError(null); - return; - } - - if (resolvedView() === "api" && (selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - setSelectedCloudMethod(null); - setApiKeyInput(""); - setLocalError(null); - return; - } - if (resolvedView() === "cloud" && (selectedEntry()?.methods.length ?? 0) > 1) { - setView("method"); - setSelectedCloudMethod(null); - setLocalError(null); - return; - } - resetState(); - }; - - const submittingLabel = () => { - if (!props.submitting) return null; - if (resolvedView() === "api") return "Saving API key..."; - if (resolvedView() === "cloud") return "Connecting organization provider..."; - if (resolvedView() === "oauth-code") return "Verifying authorization code..."; - if (resolvedView() === "oauth-auto") return "Waiting for OAuth confirmation..."; - return "Opening authentication..."; - }; - - const stepEntryIndex = (delta: number) => { - const total = filteredEntries().length; - if (total <= 0) { - setActiveEntryIndex(0); - return; - } - setActiveEntryIndex((current) => { - const normalized = ((current % total) + total) % total; - return (normalized + delta + total) % total; - }); - }; - - const handleListKeyDown = (event: KeyboardEvent) => { - if (resolvedView() !== "list") return; - if (event.key === "ArrowDown") { - event.preventDefault(); - stepEntryIndex(1); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - stepEntryIndex(-1); - return; - } - if (event.key === "Enter") { - if (event.isComposing || (event as KeyboardEvent & { keyCode?: number }).keyCode === 229) return; - const entry = filteredEntries()[activeEntryIndex()]; - if (!entry) return; - event.preventDefault(); - handleEntrySelect(entry); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - handleClose(); - } - }; - - const methodDescription = (entry: ProviderAuthEntry, method: ProviderAuthMethod) => { - const label = methodLabel(method).toLowerCase(); - if (isOpenAiProvider(entry.id, entry.name) && (label.includes("headless") || label.includes("device"))) { - return isRemoteWorker() - ? "Use OpenAI's device flow for remote workers, where the browser callback may not resolve on your local machine." - : "Use OpenAI's device flow when the local browser callback is unreliable."; - } - if (method.type === "oauth") { - return "Continue in the browser and let OpenWork finish the connection automatically."; - } - if (method.type === "cloud") { - return method.description ?? "Use the provider and credential managed by your organization."; - } - return "Paste a secret key that OpenWork stores locally on this device."; - }; - - return ( - -
-
-
-
-

Connect providers

-

Sign in to services or use providers managed by your organization.

-
- -
- -
-
- -
- Loading providers... -
-
- } - > -
- {errorMessage()} -
- -
- - -
-

{restrictionMessage()}

-
- -
-
-
- - -
- -
-
- - { - setSearchQuery(event.currentTarget.value); - setActiveEntryIndex(0); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - class="w-full rounded-xl bg-gray-2 px-9 py-2.5 text-[13px] text-gray-12 placeholder:text-gray-9 border border-gray-6/60 focus:border-gray-8 focus:bg-gray-1 focus:outline-none transition-colors shadow-sm" - /> -
- - - {entries().length ? "No providers match your search." : "No providers available."} -
- } - > - - {(entry, index) => { - const idx = () => index(); - return ( -
- - ); - }} - -
- -
Arrow keys to navigate, Enter to select.
-
- - - -
-
-
-
{selectedEntry()!.name}
-
Choose how you'd like to connect.
-
- -
-
- - {(method) => ( - - )} - -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Paste your API key to connect.
-
- -
- { - setApiKeyInput(event.currentTarget.value); - if (localError()) setLocalError(null); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - /> - 0}> -
- Env vars: {selectedEntry()!.env.join(", ")} -
-
-
-
- Keys are stored locally by OpenCode. -
- -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Connect with the provider managed by your organization.
-
- -
-
- {selectedCloudMethod()!.description ?? "Use the provider and credential managed by your organization."} -
- 0}> -
- {(selectedCloudMethod()!.modelCount ?? 0)} curated model{(selectedCloudMethod()!.modelCount ?? 0) === 1 ? "" : "s"} will be added to this workspace. -
-
- 0}> -
- Env vars: {selectedCloudMethod()!.env!.join(", ")} -
-
-
-
- OpenWork will install the provider config and use the credential stored for your org. -
- -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Finish OAuth by pasting the authorization code.
-
- -
-
- Complete sign-in in your browser, then paste the code here. -
- -
- {oauthInstructions()} -
-
- { - setOauthCodeInput(event.currentTarget.value); - if (localError()) setLocalError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - void handleOauthCodeSubmit(); - }} - autocomplete="off" - autocapitalize="off" - spellcheck={false} - disabled={actionDisabled()} - /> -
- - -
-
-
- - -
-
-
-
{selectedEntry()!.name}
-
Waiting for browser confirmation.
-
- -
- Sign in in the browser tab we just opened. We will complete the connection automatically.
- } - > -
-
You'll need to sign in to your OpenAI account and provide the code below.
-
- The first time you do this you'll need to enable Device auth in your account settings. -
-
ChatGPT > Account Settings > Security > Enable device code authorization
-
When you're ready, copy the code below, and click "Open Browser".
-
-
- -
-
-
Confirmation code
-
{oauthDisplayCode()}
-
- -
-
- - - Checking connection status automatically... -
- } - > -
- Authorization checks will start after you click Open Browser. -
- -
- -
This window will close once the provider is connected.
-
-
-
-
- -
- -
-
- {submittingLabel()} -
- -
-
- -
- ); -} diff --git a/apps/app/src/app/context/sandbox-create-mode.ts b/apps/app/src/app/context/sandbox-create-mode.ts deleted file mode 100644 index b9bb703e..00000000 --- a/apps/app/src/app/context/sandbox-create-mode.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type SandboxBackendType = "docker" | "microsandbox"; - -export type SandboxCreateModeConfig = { - backend: SandboxBackendType; - sandboxImageRef: string | null; - runtimeReadyLabel: string; - runtimeCheckingStage: string; -}; - -export const MICRO_SANDBOX_IMAGE_REF = "openwork-microsandbox:dev"; - -export function resolveSandboxCreateMode(useMicrosandbox: boolean): SandboxCreateModeConfig { - if (useMicrosandbox) { - return { - backend: "microsandbox", - sandboxImageRef: MICRO_SANDBOX_IMAGE_REF, - runtimeReadyLabel: "Microsandbox runtime ready", - runtimeCheckingStage: "Checking sandbox runtime...", - }; - } - - return { - backend: "docker", - sandboxImageRef: null, - runtimeReadyLabel: "Docker ready", - runtimeCheckingStage: "Checking Docker...", - }; -} diff --git a/apps/app/src/app/context/server.tsx b/apps/app/src/app/context/server.tsx deleted file mode 100644 index 6fb2edee..00000000 --- a/apps/app/src/app/context/server.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"; -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; - -import { isWebDeployment } from "../lib/openwork-deployment"; -import { isTauriRuntime } from "../utils"; - -export function normalizeServerUrl(input: string) { - const trimmed = input.trim(); - if (!trimmed) return; - const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`; - return withProtocol.replace(/\/+$/, ""); -} - -export function serverDisplayName(url: string) { - if (!url) return ""; - return url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); -} - -type ServerContextValue = { - url: string; - name: string; - list: string[]; - healthy: () => boolean | undefined; - setActive: (url: string) => void; - add: (url: string) => void; - remove: (url: string) => void; -}; - -const ServerContext = createContext(undefined); - -export function ServerProvider(props: ParentProps & { defaultUrl: string }) { - const [list, setList] = createSignal([]); - const [active, setActiveRaw] = createSignal(""); - const [healthy, setHealthy] = createSignal(undefined); - const [ready, setReady] = createSignal(false); - - const readStoredList = () => { - try { - const raw = window.localStorage.getItem("openwork.server.list"); - const parsed = raw ? (JSON.parse(raw) as unknown) : []; - return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []; - } catch { - return []; - } - }; - - const readStoredActive = () => { - try { - const stored = window.localStorage.getItem("openwork.server.active"); - return typeof stored === "string" ? stored : ""; - } catch { - return ""; - } - }; - - createEffect(() => { - if (typeof window === "undefined") return; - if (ready()) return; - - const fallback = normalizeServerUrl(props.defaultUrl) ?? ""; - - // In hosted web deployments served by OpenWork, OpenCode - // traffic should go through the server proxy (usually same-origin `/opencode`). - // Do not reuse any persisted localhost targets. - const forceProxy = - !isTauriRuntime() && - isWebDeployment() && - (import.meta.env.PROD || - (typeof import.meta.env?.VITE_OPENWORK_URL === "string" && - import.meta.env.VITE_OPENWORK_URL.trim().length > 0)); - if (forceProxy && fallback) { - setList([fallback]); - setActiveRaw(fallback); - setReady(true); - return; - } - - const storedList = readStoredList(); - const storedActive = normalizeServerUrl(readStoredActive()); - - const initialList = storedList.length ? storedList : fallback ? [fallback] : []; - const initialActive = storedActive || initialList[0] || fallback || ""; - - setList(initialList); - setActiveRaw(initialActive); - setReady(true); - }); - - createEffect(() => { - if (!ready()) return; - if (typeof window === "undefined") return; - - try { - window.localStorage.setItem("openwork.server.list", JSON.stringify(list())); - window.localStorage.setItem("openwork.server.active", active()); - } catch { - // ignore - } - }); - - const activeUrl = createMemo(() => active()); - - const readOpenworkToken = () => { - try { - return (window.localStorage.getItem("openwork.server.token") ?? "").trim(); - } catch { - return ""; - } - }; - - const checkHealth = async (url: string) => { - if (!url) return false; - const token = readOpenworkToken(); - const headers = token && url.includes("/opencode") ? { Authorization: `Bearer ${token}` } : undefined; - const client = createOpencodeClient({ - baseUrl: url, - headers, - signal: AbortSignal.timeout(3000), - fetch: isTauriRuntime() ? tauriFetch : undefined, - }); - return client.global - .health() - .then((result) => result.data?.healthy === true) - .catch(() => false); - }; - - createEffect(() => { - const url = activeUrl(); - if (!url) return; - - setHealthy(undefined); - - let activeRun = true; - let busy = false; - - const run = () => { - if (busy) return; - busy = true; - void checkHealth(url) - .then((next) => { - if (!activeRun) return; - setHealthy(next); - }) - .finally(() => { - busy = false; - }); - }; - - run(); - const interval = window.setInterval(run, 10_000); - - onCleanup(() => { - activeRun = false; - window.clearInterval(interval); - }); - }); - - const setActive = (input: string) => { - const next = normalizeServerUrl(input); - if (!next) return; - setActiveRaw(next); - }; - - const add = (input: string) => { - const next = normalizeServerUrl(input); - if (!next) return; - - setList((current) => { - if (current.includes(next)) return current; - return [...current, next]; - }); - setActiveRaw(next); - }; - - const remove = (input: string) => { - const next = normalizeServerUrl(input); - if (!next) return; - - setList((current) => current.filter((item) => item !== next)); - setActiveRaw((current) => { - if (current !== next) return current; - const remaining = list().filter((item) => item !== next); - return remaining[0] ?? ""; - }); - }; - - const value: ServerContextValue = { - get url() { - return activeUrl(); - }, - get name() { - return serverDisplayName(activeUrl()); - }, - get list() { - return list(); - }, - healthy, - setActive, - add, - remove, - }; - - return {props.children}; -} - -export function useServer() { - const context = useContext(ServerContext); - if (!context) { - throw new Error("Server context is missing"); - } - return context; -} diff --git a/apps/app/src/app/context/session.ts b/apps/app/src/app/context/session.ts deleted file mode 100644 index 7e7ad83c..00000000 --- a/apps/app/src/app/context/session.ts +++ /dev/null @@ -1,2143 +0,0 @@ -import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import { t, currentLocale } from "../../i18n"; -import { createStore, produce, reconcile } from "solid-js/store"; - -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; - -import type { - Client, - MessageInfo, - MessageWithParts, - ModelRef, - OpencodeEvent, - PendingPermission, - PendingQuestion, - PlaceholderAssistantMessage, - PlaceholderMessageInfo, - ReloadReason, - ReloadTrigger, - SessionCompactionState, - SessionErrorTurn, - TodoItem, -} from "../types"; -import { - addOpencodeCacheHint, - isVisibleTextPart, - modelFromUserMessage, - normalizeDirectoryPath, - normalizeEvent, - normalizeSessionStatus, - safeStringify, -} from "../utils"; -import { unwrap } from "../lib/opencode"; -import { recordDevLog } from "../lib/dev-log"; -import { abortSessionSafe } from "../lib/opencode-session"; -import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; -import { describeDirectoryScope, toSessionTransportDirectory } from "../lib/session-scope"; -import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "../types"; - -export type SessionModelState = { - overrides: Record; - resolved: Record; -}; - -export type SessionStore = ReturnType; - -type BlueprintSeedMessage = { role?: "assistant" | "user" | null; text?: string | null }; - -const SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX = "blueprint-seed:"; - -type StoreState = { - sessions: Session[]; - sessionInfoById: Record; - sessionStatus: Record; - sessionErrorTurns: Record; - messages: Record; - parts: Record; - todos: Record; - pendingPermissions: PendingPermission[]; - pendingQuestions: PendingQuestion[]; - events: OpencodeEvent[]; - sessionCompaction: Record; -}; - -const sortById = (list: T[]) => - list.slice().sort((a, b) => a.id.localeCompare(b.id)); - -const sessionActivity = (session: Session) => - session.time?.updated ?? session.time?.created ?? 0; - -const sortSessionsByActivity = (list: Session[]) => - list - .slice() - .sort((a, b) => { - const delta = sessionActivity(b) - sessionActivity(a); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - -const SYNTHETIC_CONTINUE_CONTROL_PATTERN = - /^\s*continue if you have next steps,\s*or stop and ask for clarification if you are unsure how to proceed\.?\s*$/i; -const SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN = - /^\s*summarize the task tool output above and continue with your task\.?\s*$/i; -const COMPACTION_DIAGNOSTIC_WINDOW_MS = 60_000; -const COMPACTION_LOOP_WARN_THRESHOLD = 3; -const COMPACTION_LOOP_WARN_MIN_INTERVAL_MS = 10_000; -const SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD = 5; -const SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS = 30_000; -const INITIAL_SESSION_MESSAGE_LIMIT = 140; -const SESSION_MESSAGE_LOAD_CHUNK = 120; - -const createPlaceholderMessage = (part: Part): PlaceholderAssistantMessage => ({ - id: part.messageID, - sessionID: part.sessionID, - role: "assistant", - time: { created: Date.now() }, - parentID: "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, -}); - -const upsertSession = (list: Session[], next: Session) => { - const index = list.findIndex((session) => session.id === next.id); - if (index === -1) return sortSessionsByActivity([...list, next]); - const copy = list.slice(); - copy[index] = next; - return sortSessionsByActivity(copy); -}; - -const removeSession = (list: Session[], sessionID: string) => list.filter((session) => session.id !== sessionID); - -const upsertMessageInfo = (list: MessageInfo[], next: MessageInfo) => { - const index = list.findIndex((message) => message.id === next.id); - if (index === -1) return sortById([...list, next]); - const copy = list.slice(); - copy[index] = next; - return copy; -}; - -const removeMessageInfo = (list: MessageInfo[], messageID: string) => - list.filter((message) => message.id !== messageID); - -const upsertPartInfo = (list: Part[], next: Part) => { - const index = list.findIndex((part) => part.id === next.id); - if (index === -1) return sortById([...list, next]); - const copy = list.slice(); - const existing = copy[index] as Part & Record; - const incoming = next as Part & Record; - if ((incoming.type === "text" || incoming.type === "reasoning") && typeof existing.text === "string") { - const nextText = typeof incoming.text === "string" ? incoming.text : ""; - copy[index] = { ...existing, ...incoming, text: nextText || existing.text } as Part; - } else { - copy[index] = next; - } - return copy; -}; - -const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID); - -const appendPartDelta = (list: Part[], messageID: string, sessionID: string | null, partID: string, field: string, delta: string) => { - if (!delta) return list; - const index = list.findIndex((part) => part.id === partID); - if (index === -1) { - if (field !== "text" && field !== "reasoning") return list; - const synthetic = { - id: partID, - messageID, - sessionID: sessionID ?? "", - type: field === "reasoning" ? "reasoning" : "text", - text: delta, - } as Part; - return sortById([...list, synthetic]); - } - - const existing = list[index] as Part & Record; - const current = existing[field]; - if (current !== undefined && typeof current !== "string") { - return list; - } - - const nextValue = `${typeof current === "string" ? current : ""}${delta}`; - if (nextValue === current) return list; - - const copy = list.slice(); - copy[index] = { ...existing, [field]: nextValue } as Part; - return copy; -}; - -export function createSessionStore(options: { - client: () => Client | null; - selectedWorkspaceRoot: () => string; - selectedSessionId: () => string | null; - setSelectedSessionId: (id: string | null) => void; - setPrompt: (value: string) => void; - sessionModelState: () => SessionModelState; - setSessionModelState: (updater: (current: SessionModelState) => SessionModelState) => SessionModelState; - lastUserModelFromMessages: (messages: MessageWithParts[]) => ModelRef | null; - developerMode: () => boolean; - setError: (message: string | null) => void; - setSseConnected: (connected: boolean) => void; - markReloadRequired?: (reason: ReloadReason, trigger?: ReloadTrigger) => void; - onHotReloadApplied?: () => void; -}) { - - const sessionDebugEnabled = () => options.developerMode(); - - const sessionDebug = (label: string, payload?: unknown) => { - if (!sessionDebugEnabled()) return; - try { - recordDevLog(true, { level: "debug", source: "session", label, payload }); - if (payload === undefined) { - console.log(`[WSDBG] ${label}`); - } else { - console.log(`[WSDBG] ${label}`, payload); - } - } catch { - // ignore - } - }; - - const sessionWarn = (label: string, payload?: unknown) => { - if (!sessionDebugEnabled()) return; - try { - recordDevLog(true, { level: "warn", source: "session", label, payload }); - if (payload === undefined) { - console.warn(`[WSWARN] ${label}`); - } else { - console.warn(`[WSWARN] ${label}`, payload); - } - } catch { - // ignore - } - }; - const MAX_RELOAD_DETECTION_KEYS = 5000; - - const [store, setStore] = createStore({ - sessions: [], - sessionInfoById: {}, - sessionStatus: {}, - sessionErrorTurns: {}, - messages: {}, - parts: {}, - todos: {}, - pendingPermissions: [], - pendingQuestions: [], - events: [], - sessionCompaction: {}, - }); - const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false); - const [blueprintSeedMessagesBySessionId, setBlueprintSeedMessagesBySessionId] = createSignal< - Record - >({}); - const [messageLimitBySession, setMessageLimitBySession] = createSignal>({}); - const [messageCompleteBySession, setMessageCompleteBySession] = createSignal>({}); - const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal>({}); - const [loadedScopeRoot, setLoadedScopeRoot] = createSignal(""); - const reloadDetectionSet = new Set(); - const invalidToolDetectionSet = new Set(); - const pendingCompactionModeBySession = new Map(); - const syntheticContinueEventTimesBySession = new Map(); - const syntheticTaskSummaryEventTimesBySession = new Map(); - const syntheticContinueLoopLastWarnAtBySession = new Map(); - const syntheticLoopLastAbortAtByKey = new Map(); - - const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i; - const skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i; - const commandPathPattern = /[\\/]\.opencode[\\/](command|commands)[\\/]/i; - const commandNamePattern = /[\\/]\.opencode[\\/](?:command|commands)[\\/]+([^\\/]+)/i; - const agentPathPattern = /[\\/]\.opencode[\\/](agent|agents)[\\/]/i; - const agentNamePattern = /[\\/]\.opencode[\\/](?:agent|agents)[\\/]+([^\\/]+)/i; - const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i; - const opencodePathPattern = /(?:^|[\\/])\.opencode[\\/]/i; - const openworkConfigPattern = /[\\/]\.opencode[\\/]openwork\.json\b/i; - const mutatingTools = new Set(["write", "edit", "apply_patch"]); - - const extractSearchText = (value: unknown) => { - if (!value) return ""; - if (typeof value === "string") return value; - if (typeof value === "number") return String(value); - return safeStringify(value); - }; - - const detectReloadReason = (value: unknown): ReloadReason | null => { - const text = extractSearchText(value); - if (!text) return null; - if (openworkConfigPattern.test(text)) return null; - if (skillPathPattern.test(text)) return "skills"; - if (commandPathPattern.test(text)) return "commands"; - if (agentPathPattern.test(text)) return "agents"; - if (opencodeConfigPattern.test(text)) return "config"; - if (opencodePathPattern.test(text)) return "config"; - return null; - }; - - const detectReloadTriggerFromText = (text: string): ReloadTrigger | null => { - if (openworkConfigPattern.test(text)) { - return null; - } - if (skillPathPattern.test(text)) { - const match = text.match(skillNamePattern); - return { - type: "skill", - name: match?.[1], - action: "updated", - path: match?.[0], - }; - } - - if (commandPathPattern.test(text)) { - const match = text.match(commandNamePattern); - const raw = match?.[1]; - const name = raw ? raw.replace(/\.md$/i, "") : undefined; - return { - type: "command", - name, - action: "updated", - path: match?.[0], - }; - } - - if (agentPathPattern.test(text)) { - const match = text.match(agentNamePattern); - return { - type: "agent", - name: match?.[1], - action: "updated", - path: match?.[0], - }; - } - - if (opencodeConfigPattern.test(text) || opencodePathPattern.test(text)) { - return { - type: "config", - action: "updated", - }; - } - return null; - }; - - const detectReloadReasonDeep = (value: unknown): ReloadReason | null => { - if (!value) return null; - if (typeof value === "string" || typeof value === "number") { - return detectReloadReason(value); - } - if (Array.isArray(value)) { - for (const entry of value) { - const reason = detectReloadReasonDeep(entry); - if (reason) return reason; - } - return null; - } - if (typeof value === "object") { - for (const entry of Object.values(value as Record)) { - const reason = detectReloadReasonDeep(entry); - if (reason) return reason; - } - } - return null; - }; - - const detectReloadTriggerDeep = (value: unknown): ReloadTrigger | null => { - if (!value) return null; - if (typeof value === "string" || typeof value === "number") { - return detectReloadTriggerFromText(String(value)); - } - if (Array.isArray(value)) { - for (const entry of value) { - const trigger = detectReloadTriggerDeep(entry); - if (trigger) return trigger; - } - return null; - } - if (typeof value === "object") { - for (const entry of Object.values(value as Record)) { - const trigger = detectReloadTriggerDeep(entry); - if (trigger) return trigger; - } - } - return null; - }; - - const detectReloadFromPart = (part: Part): { reason: ReloadReason; trigger?: ReloadTrigger } | null => { - if (part.type !== "tool") return null; - const record = part as Record; - const toolName = typeof record.tool === "string" ? record.tool : ""; - if (!mutatingTools.has(toolName)) return null; - const state = (record.state ?? {}) as Record; - const reason = - detectReloadReasonDeep(state.input) || - detectReloadReasonDeep(state.patch) || - detectReloadReasonDeep(state.diff); - if (!reason) return null; - const trigger = - detectReloadTriggerDeep(state.input) || - detectReloadTriggerDeep(state.patch) || - detectReloadTriggerDeep(state.diff); - return { reason, trigger: trigger ?? undefined }; - }; - - const maybeMarkReloadRequired = (part: Part) => { - if (!options.markReloadRequired) return; - if (!part?.id || !part.messageID) return; - - const root = normalizeDirectoryPath(options.selectedWorkspaceRoot()); - if (root) { - const session = store.sessions.find((candidate) => candidate.id === part.sessionID) ?? null; - const sessionRoot = normalizeDirectoryPath(session?.directory ?? ""); - if (!sessionRoot || sessionRoot !== root) { - return; - } - } - - const key = `${part.messageID}:${part.id}`; - if (reloadDetectionSet.has(key)) return; - const detection = detectReloadFromPart(part); - if (!detection) return; - reloadDetectionSet.add(key); - options.markReloadRequired(detection.reason, detection.trigger); - }; - - const toolErrorText = (part: Part) => { - if (part.type !== "tool") return ""; - const record = part as any; - const state = (record.state ?? {}) as Record; - const title = typeof state.title === "string" ? state.title : ""; - const error = typeof state.error === "string" ? state.error : ""; - const detail = typeof state.detail === "string" ? state.detail : ""; - return [title, error, detail].filter(Boolean).join("\n"); - }; - - const isInvalidToolError = (part: Part) => { - if (part.type !== "tool") return false; - const haystack = toolErrorText(part).toLowerCase(); - if (!haystack) return false; - return ( - haystack.includes("invalid tool") || - haystack.includes("model tried to call") || - haystack.includes("unavailable tool") || - haystack.includes("unknown tool") || - haystack.includes("tool not found") - ); - }; - - const invalidToolNextStepHint = (part: Part) => { - const record = part as any; - const name = typeof record.tool === "string" ? record.tool : ""; - const lower = name.toLowerCase(); - if (lower.includes("browser") || lower.includes("chrome") || lower.includes("devtools")) { - return "Chrome MCP is not ready yet. Open the MCP tab, connect `Control Chrome`, then retry."; - } - return "Try again, or switch to an agent/prompt that only uses available tools in this worker."; - }; - - const maybeHandleInvalidToolError = (part: Part) => { - if (!options.setError) return; - if (!isInvalidToolError(part)) return; - if (!part?.id || !part.messageID) return; - - const key = `${part.messageID}:${part.id}`; - if (invalidToolDetectionSet.has(key)) return; - invalidToolDetectionSet.add(key); - - // Ensure the UI doesn't get stuck in a "Responding" state when the model - // tries to call a tool that isn't available. - if (part.sessionID) { - setStore("sessionStatus", part.sessionID, "idle"); - } - - const record = part as any; - const tool = typeof record.tool === "string" && record.tool.trim() ? record.tool.trim() : "(unknown tool)"; - const hint = invalidToolNextStepHint(part); - options.setError(`Invalid tool call: ${tool}.\n\n${hint}`); - }; - - const isSyntheticContinueControlPart = (part: Part) => { - if (part.type !== "text") return false; - const record = part as Part & { text?: unknown; synthetic?: unknown; ignored?: unknown }; - if (record.synthetic !== true) return false; - if (record.ignored === true) return false; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return false; - return SYNTHETIC_CONTINUE_CONTROL_PATTERN.test(text); - }; - - const isSyntheticTaskSummaryControlPart = (part: Part) => { - if (part.type !== "text") return false; - const record = part as Part & { text?: unknown; synthetic?: unknown; ignored?: unknown }; - if (record.synthetic !== true) return false; - if (record.ignored === true) return false; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return false; - return SYNTHETIC_TASK_SUMMARY_CONTROL_PATTERN.test(text); - }; - - const recordSyntheticContinueDiagnostic = (part: Part) => { - if (!isSyntheticContinueControlPart(part)) return; - const sessionID = part.sessionID; - const now = Date.now(); - const windowStart = now - COMPACTION_DIAGNOSTIC_WINDOW_MS; - const previous = syntheticContinueEventTimesBySession.get(sessionID) ?? []; - const next = previous.filter((timestamp) => timestamp >= windowStart); - next.push(now); - syntheticContinueEventTimesBySession.set(sessionID, next); - - const countInWindow = next.length; - recordPerfLog(sessionDebugEnabled(), "session.compaction", "synthetic-continue", { - sessionID, - messageID: part.messageID, - partID: part.id, - countPerMinute: countInWindow, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - - if (countInWindow < COMPACTION_LOOP_WARN_THRESHOLD) return; - - const lastWarnAt = syntheticContinueLoopLastWarnAtBySession.get(sessionID) ?? 0; - if (now - lastWarnAt < COMPACTION_LOOP_WARN_MIN_INTERVAL_MS) return; - syntheticContinueLoopLastWarnAtBySession.set(sessionID, now); - sessionWarn("compaction:synthetic-continue-loop", { - sessionID, - countPerMinute: countInWindow, - }); - recordPerfLog(sessionDebugEnabled(), "session.compaction", "synthetic-continue-loop-suspected", { - sessionID, - countPerMinute: countInWindow, - threshold: COMPACTION_LOOP_WARN_THRESHOLD, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - }; - - const recordSyntheticTaskSummaryDiagnostic = (part: Part) => { - if (!isSyntheticTaskSummaryControlPart(part)) return; - const sessionID = part.sessionID; - const now = Date.now(); - const windowStart = now - COMPACTION_DIAGNOSTIC_WINDOW_MS; - const previous = syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? []; - const next = previous.filter((timestamp) => timestamp >= windowStart); - next.push(now); - syntheticTaskSummaryEventTimesBySession.set(sessionID, next); - - recordPerfLog(sessionDebugEnabled(), "session.task", "synthetic-task-summary-control", { - sessionID, - messageID: part.messageID, - partID: part.id, - countPerMinute: next.length, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - }; - - const addError = (error: unknown, fallback = t("app.unknown_error", currentLocale())) => { - const message = error instanceof Error ? error.message : fallback; - if (!message) return; - options.setError(addOpencodeCacheHint(message)); - }; - - const appendSessionErrorTurn = (sessionID: string, message: string | null) => { - const text = message?.trim() ?? ""; - if (!sessionID || !text) return; - - const list = store.messages[sessionID] ?? []; - const lastMessage = list.length > 0 ? list[list.length - 1] : null; - const afterMessageID = lastMessage?.id ?? null; - - setStore("sessionErrorTurns", sessionID, (current) => { - const existing = current ?? []; - const previous = existing[existing.length - 1]; - if (previous && previous.text === text && previous.afterMessageID === afterMessageID) { - return existing; - } - - return existing.concat({ - id: `${SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX}${sessionID}:${Date.now()}:${existing.length}`, - text, - afterMessageID, - time: Date.now(), - }); - }); - }; - - const maybeAbortSyntheticControlLoop = (part: Part) => { - const sessionID = part.sessionID; - if (!sessionID) return; - - const kind = isSyntheticTaskSummaryControlPart(part) - ? "task-summary" - : isSyntheticContinueControlPart(part) - ? "compaction-continue" - : null; - if (!kind) return; - - const events = - kind === "task-summary" - ? syntheticTaskSummaryEventTimesBySession.get(sessionID) ?? [] - : syntheticContinueEventTimesBySession.get(sessionID) ?? []; - const threshold = - kind === "task-summary" - ? SYNTHETIC_TASK_SUMMARY_LOOP_ABORT_THRESHOLD - : COMPACTION_LOOP_WARN_THRESHOLD; - if (events.length < threshold) return; - - const key = `${kind}:${sessionID}`; - const now = Date.now(); - const lastAbortAt = syntheticLoopLastAbortAtByKey.get(key) ?? 0; - if (now - lastAbortAt < SYNTHETIC_CONTROL_LOOP_ABORT_MIN_INTERVAL_MS) return; - syntheticLoopLastAbortAtByKey.set(key, now); - - const message = - kind === "task-summary" - ? "OpenWork stopped this run after detecting a likely synthetic task-summary loop. The engine kept asking itself to summarize task output and continue, which can repeat Goal/Instructions/Discoveries summaries without making progress." - : "OpenWork stopped this run after detecting a likely auto-compaction continuation loop. The engine kept injecting synthetic continue prompts after compaction, which can burn tokens without advancing the task."; - - sessionWarn("session.synthetic-loop.abort", { - sessionID, - kind, - countPerMinute: events.length, - }); - recordPerfLog(sessionDebugEnabled(), "session.loop", "abort-suspected-synthetic-loop", { - sessionID, - kind, - countPerMinute: events.length, - threshold, - windowMs: COMPACTION_DIAGNOSTIC_WINDOW_MS, - }); - - const c = options.client(); - if (!c) { - appendSessionErrorTurn(sessionID, message); - options.setError(message); - setStore("sessionStatus", sessionID, "idle"); - return; - } - - void abortSessionSafe(c, sessionID).finally(() => { - appendSessionErrorTurn(sessionID, message); - options.setError(message); - setStore("sessionStatus", sessionID, "idle"); - }); - }; - - const truncateErrorField = (value: unknown, max = 500) => { - if (typeof value !== "string") return null; - const text = value.trim(); - if (!text) return null; - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 3))}...`; - }; - - const inferHttpStatus = (value: string | null) => { - if (!value) return null; - const match = value.match(/\b(?:status|code|http)\s*(?:=|:)?\s*(401|403|413|429)\b/i) || - value.match(/\b(401|403|413|429)\b/); - if (!match) return null; - const parsed = Number.parseInt(match[1], 10); - if (!Number.isFinite(parsed)) return null; - return parsed; - }; - - const getNestedRecords = (source: Record) => { - const records: Record[] = [source]; - const data = source.data; - if (data && typeof data === "object") records.push(data as Record); - const cause = source.cause; - if (cause && typeof cause === "object") { - const causeRecord = cause as Record; - records.push(causeRecord); - const causeData = causeRecord.data; - if (causeData && typeof causeData === "object") records.push(causeData as Record); - } - return records; - }; - - const firstStringField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = truncateErrorField(record[key], 800); - if (value) return value; - } - } - return null; - }; - - const firstNumberField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = record[key]; - if (typeof value !== "number" || !Number.isFinite(value)) continue; - return value; - } - } - return null; - }; - - const firstBooleanField = (records: Record[], keys: string[]) => { - for (const record of records) { - for (const key of keys) { - const value = record[key]; - if (typeof value !== "boolean") continue; - return value; - } - } - return null; - }; - - const formatSessionError = (errorObj: Record) => { - const records = getNestedRecords(errorObj); - const errorName = typeof errorObj.name === "string" ? errorObj.name : "UnknownError"; - const rawMessage = firstStringField(records, ["message", "detail", "reason"]); - const responseBody = firstStringField(records, ["responseBody", "body", "response"]); - const providerID = firstStringField(records, ["providerID", "providerId", "provider"]); - const code = firstStringField(records, ["code", "errorCode"]); - const statusCode = firstNumberField(records, ["statusCode", "status"]); - const inferred = inferHttpStatus(rawMessage) ?? inferHttpStatus(responseBody); - const effectiveStatus = statusCode ?? inferred; - const isRetryable = firstBooleanField(records, ["isRetryable", "retryable"]); - - const heading = (() => { - if (errorName === "ProviderAuthError") return `Provider auth error${providerID ? ` (${providerID})` : ""}`; - if (errorName === "APIError") { - if (effectiveStatus === 401 || effectiveStatus === 403) return t("app.error_auth_failed", currentLocale()); - if (effectiveStatus === 413) return "Context too large"; - if (effectiveStatus === 429) return t("app.error_rate_limit", currentLocale()); - return `API error${effectiveStatus ? ` (${effectiveStatus})` : ""}`; - } - if (effectiveStatus === 401 || effectiveStatus === 403) return t("app.error_auth_failed", currentLocale()); - if (effectiveStatus === 413) return "Context too large"; - if (effectiveStatus === 429) return t("app.error_rate_limit", currentLocale()); - if (errorName === "MessageOutputLengthError") return "Output length limit exceeded"; - return errorName.replace(/([a-z])([A-Z])/g, "$1 $2"); - })(); - - const lines = [heading]; - if (rawMessage && rawMessage !== heading) lines.push(rawMessage); - if (effectiveStatus === 413) { - lines.push("Tip: Try compacting the session, or start a new session if the issue persists."); - } - if (providerID && errorName !== "ProviderAuthError") lines.push(`Provider: ${providerID}`); - if (effectiveStatus && errorName !== "APIError") lines.push(`Status: ${effectiveStatus}`); - if (code) lines.push(`Code: ${code}`); - if (isRetryable !== null) lines.push(`Retryable: ${isRetryable ? "yes" : "no"}`); - if (responseBody) lines.push(`Response: ${responseBody}`); - return lines.join("\n"); - }; - - const withTimeout = async (promise: Promise, ms: number, label: string) => { - let timeoutId: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), ms); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - }; - - let selectRunCounter = 0; - let selectVersion = 0; - const selectInFlightBySession = new Map>(); - const ensureInFlightBySession = new Map>(); - - const rememberSession = (session: Session) => { - setStore("sessionInfoById", session.id, session); - }; - - const rememberSessions = (list: Session[]) => { - if (!list.length) return; - batch(() => { - list.forEach((session) => { - setStore("sessionInfoById", session.id, session); - }); - }); - }; - - const sessionById = (id: string | null) => { - if (!id) return null; - return store.sessionInfoById[id] ?? store.sessions.find((session) => session.id === id) ?? null; - }; - - const messageIdFromInfo = (message: MessageWithParts) => { - const id = (message.info as { id?: string | number }).id; - if (typeof id === "string") return id; - if (typeof id === "number") return String(id); - return ""; - }; - - const createSyntheticSessionErrorMessage = ( - sessionID: string, - errorTurn: SessionErrorTurn, - ): MessageWithParts => { - const info: PlaceholderAssistantMessage = { - id: errorTurn.id, - sessionID, - role: "assistant", - time: { created: errorTurn.time, completed: errorTurn.time }, - parentID: errorTurn.afterMessageID ?? "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }; - - return { - info, - parts: [ - { - id: `${errorTurn.id}:text`, - sessionID, - messageID: errorTurn.id, - type: "text", - text: errorTurn.text, - } as Part, - ], - }; - }; - - const createSyntheticBlueprintSeedMessage = ( - sessionID: string, - index: number, - seed: BlueprintSeedMessage, - ): MessageWithParts => { - const messageId = `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index}`; - const role = seed.role === "user" ? "user" : "assistant"; - const text = seed.text?.trim() ?? ""; - const createdAt = Math.max(1, index + 1); - const info: PlaceholderMessageInfo = { - id: messageId, - sessionID, - role, - time: { created: createdAt, completed: createdAt }, - parentID: index > 0 ? `${SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX}${sessionID}:${index - 1}` : "", - modelID: "", - providerID: "", - mode: "", - agent: "", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }; - - return { - info, - parts: [ - { - id: `${messageId}:text`, - sessionID, - messageID: messageId, - type: "text", - text, - } as Part, - ], - }; - }; - - const insertSyntheticBlueprintSeedMessages = ( - list: MessageWithParts[], - sessionID: string | null, - seeds: BlueprintSeedMessage[], - ) => { - if (!sessionID || seeds.length === 0) return list; - if (list.length > 0) return list; - const existingIds = new Set(list.map((message) => messageIdFromInfo(message))); - const synthetic = seeds - .map((seed, index) => createSyntheticBlueprintSeedMessage(sessionID, index, seed)) - .filter((message) => !existingIds.has(messageIdFromInfo(message))); - if (!synthetic.length) return list; - return [...synthetic, ...list]; - }; - - const insertSyntheticSessionErrors = ( - list: MessageWithParts[], - sessionID: string | null, - errorTurns: SessionErrorTurn[], - ) => { - if (!sessionID || errorTurns.length === 0) return list; - - const next = list.slice(); - errorTurns.forEach((errorTurn) => { - if (next.some((message) => messageIdFromInfo(message) === errorTurn.id)) return; - const syntheticMessage = createSyntheticSessionErrorMessage(sessionID, errorTurn); - const anchorIndex = errorTurn.afterMessageID - ? next.findIndex((message) => messageIdFromInfo(message) === errorTurn.afterMessageID) - : -1; - - if (anchorIndex === -1) { - next.push(syntheticMessage); - return; - } - - next.splice(anchorIndex + 1, 0, syntheticMessage); - }); - - return next; - }; - - const upsertLocalSession = (next: Session | null | undefined) => { - const id = (next as { id?: string } | null)?.id ?? ""; - if (!id) return; - - const current = sessions(); - const index = current.findIndex((session) => session.id === id); - if (index === -1) { - setStore("sessions", sortSessionsByActivity([...current, next as Session])); - rememberSession(next as Session); - return; - } - - const copy = current.slice(); - copy[index] = next as Session; - rememberSession(next as Session); - setStore("sessions", sortSessionsByActivity(copy)); - }; - - const messagesBySessionId = (id: string | null): MessageWithParts[] => { - if (!id) return []; - const list = store.messages[id] ?? []; - return list.map((info) => ({ info, parts: store.parts[info.id] ?? [] })); - }; - - const sessions = () => store.sessions; - const sessionStatusById = () => store.sessionStatus; - const pendingPermissions = () => store.pendingPermissions; - const pendingQuestions = () => store.pendingQuestions; - const events = () => store.events; - - const selectedSession = createMemo(() => { - return sessionById(options.selectedSessionId()); - }); - - const selectedSessionStatus = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return "idle"; - return store.sessionStatus[id] ?? "idle"; - }); - - const messages = createMemo(() => { - return messagesBySessionId(options.selectedSessionId()); - }); - - const blueprintSeedMessagesForSelectedSession = createMemo(() => { - const sessionID = options.selectedSessionId(); - if (!sessionID) return [] as BlueprintSeedMessage[]; - return blueprintSeedMessagesBySessionId()[sessionID] ?? []; - }); - - const visibleMessages = createMemo(() => { - const sessionID = options.selectedSessionId(); - const errorTurns = sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []; - const blueprintSeeds = blueprintSeedMessagesForSelectedSession(); - const list = messages().filter((message) => { - const id = messageIdFromInfo(message); - return !id.startsWith(SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX) && !id.startsWith(SYNTHETIC_BLUEPRINT_SEED_MESSAGE_PREFIX); - }); - const revert = selectedSession()?.revert?.messageID ?? null; - const visible = !revert - ? list - : list.filter((message) => { - const id = messageIdFromInfo(message); - return Boolean(id) && id < revert; - }); - return insertSyntheticSessionErrors( - insertSyntheticBlueprintSeedMessages(visible, sessionID, blueprintSeeds), - sessionID, - errorTurns, - ); - }); - - const restorePromptFromUserMessage = (message: MessageWithParts) => { - const text = message.parts - .filter(isVisibleTextPart) - .map((part) => String((part as { text?: string }).text ?? "")) - .join(""); - options.setPrompt(text); - }; - - const todos = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return []; - return store.todos[id] ?? []; - }); - - const selectedSessionHasEarlierMessages = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return false; - return !messageCompleteBySession()[id]; - }); - - const selectedSessionLoadingEarlierMessages = createMemo(() => { - const id = options.selectedSessionId(); - if (!id) return false; - return Boolean(messageLoadBusyBySession()[id]); - }); - - async function loadSessions(scopeRoot?: string) { - const c = options.client(); - if (!c) return; - - // IMPORTANT: OpenCode's session.list() supports server-side filtering by directory. - // Use it to avoid fetching every session across every workspace root. - // - // Note: Use the same transport path format we send for create/delete so the - // server-side strict directory equality checks hit the same stored value. - const queryDirectory = toSessionTransportDirectory(scopeRoot) || undefined; - - sessionDebug("sessions:load:request", { - scopeRoot: scopeRoot ?? null, - scopeScope: describeDirectoryScope(scopeRoot), - queryDirectory: queryDirectory ?? null, - queryScope: describeDirectoryScope(queryDirectory), - selectedWorkspaceRoot: options.selectedWorkspaceRoot?.() ?? null, - activeWorkspaceScope: describeDirectoryScope(options.selectedWorkspaceRoot?.() ?? null), - }); - - const start = Date.now(); - sessionDebug("sessions:load:start", { - scopeRoot: scopeRoot ?? null, - scopeScope: describeDirectoryScope(scopeRoot), - queryDirectory: queryDirectory ?? null, - queryScope: describeDirectoryScope(queryDirectory), - }); - const list = unwrap(await c.session.list({ directory: queryDirectory, roots: true })); - sessionDebug("sessions:load:response", { - count: list.length, - sessions: list.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - directoryScope: describeDirectoryScope(session.directory), - parentID: session.parentID, - })), - }); - sessionDebug("sessions:load:raw", { count: list.length, ms: Date.now() - start }); - - // Defensive client-side filter in case the server returns sessions spanning - // multiple roots (e.g. older servers or proxies). - const root = normalizeDirectoryPath(scopeRoot); - const filtered = root - ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) - : list; - sessionDebug("sessions:load:filtered-list", { - root: root || null, - count: filtered.length, - sessions: filtered.map((session) => ({ - id: session.id, - title: session.title, - directory: session.directory, - parentID: session.parentID, - })), - }); - sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length }); - setLoadedScopeRoot(root); - rememberSessions(filtered); - setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" })); - } - - async function renameSession(sessionID: string, title: string) { - const c = options.client(); - if (!c) return; - const trimmed = title.trim(); - if (!trimmed) { - throw new Error(t("app.error_session_name_required", currentLocale())); - } - const next = unwrap(await c.session.update({ sessionID, title: trimmed })); - rememberSession(next); - setStore("sessions", (current) => upsertSession(current, next)); - } - - async function refreshPendingPermissions() { - const c = options.client(); - if (!c) return; - const list = unwrap(await c.permission.list()); - const now = Date.now(); - const byId = new Map(store.pendingPermissions.map((perm) => [perm.id, perm] as const)); - const next = list.map((perm) => ({ ...perm, receivedAt: byId.get(perm.id)?.receivedAt ?? now })); - setStore("pendingPermissions", next); - } - - async function refreshPendingQuestions() { - const c = options.client(); - if (!c) return; - const list = unwrap(await c.question.list()); - const now = Date.now(); - const byId = new Map(store.pendingQuestions.map((q) => [q.id, q] as const)); - const next = list.map((q) => ({ ...q, receivedAt: byId.get(q.id)?.receivedAt ?? now })); - setStore("pendingQuestions", next); - } - - function setMessagesForSession(sessionID: string, list: MessageWithParts[]) { - const infos = list - .map((msg) => msg.info) - .filter((info) => !!info?.id) - .map((info) => info as MessageInfo); - - const isStreaming = (store.sessionStatus[sessionID] ?? "idle") !== "idle"; - - batch(() => { - setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" })); - for (const message of list) { - const parts = message.parts.filter((part) => !!part?.id); - - if (isStreaming) { - // During active streaming, the server snapshot may have empty/stale - // text fields for in-progress parts while the local store already - // accumulated text via message.part.delta events. Merge carefully - // so we never overwrite longer local text with shorter server text. - const existingParts = store.parts[message.info.id] ?? []; - const merged = sortById(parts).map((incoming) => { - const existing = existingParts.find((p) => p.id === incoming.id); - if (!existing) return incoming; - const incomingRecord = incoming as Part & Record; - const existingRecord = existing as Part & Record; - if ( - (incoming.type === "text" || incoming.type === "reasoning") && - typeof existingRecord.text === "string" && - typeof incomingRecord.text === "string" && - existingRecord.text.length > incomingRecord.text.length - ) { - return { ...incoming, text: existingRecord.text } as Part; - } - return incoming; - }); - // Also keep any local-only parts (created from early deltas) that - // the server snapshot doesn't know about yet. - for (const existing of existingParts) { - if (!merged.find((p) => p.id === existing.id)) { - merged.push(existing); - } - } - setStore("parts", message.info.id, reconcile(sortById(merged), { key: "id" })); - } else { - setStore("parts", message.info.id, reconcile(sortById(parts), { key: "id" })); - } - } - }); - } - - async function ensureSessionLoaded(sessionID: string) { - const id = sessionID.trim(); - if (!id) return; - if ((store.messages[id]?.length ?? 0) > 0) return; - if (sessionById(id) && messageLimitBySession()[id] !== undefined) return; - - const existing = ensureInFlightBySession.get(id); - if (existing) return existing; - - const c = options.client(); - if (!c) return; - - const run = (async () => { - setMessageLoadBusyBySession((prev) => ({ ...prev, [id]: true })); - try { - const [info, msgs] = await Promise.all([ - withTimeout(c.session.get({ sessionID: id }), 8000, "session.get"), - withTimeout(c.session.messages({ sessionID: id, limit: INITIAL_SESSION_MESSAGE_LIMIT }), 12000, "session.messages"), - ]); - const nextSession = unwrap(info); - const nextMessages = unwrap(msgs); - rememberSession(nextSession); - setStore("sessions", (current) => upsertSession(current, nextSession)); - setMessagesForSession(id, nextMessages); - setMessageLimitBySession((prev) => ({ ...prev, [id]: INITIAL_SESSION_MESSAGE_LIMIT })); - setMessageCompleteBySession((prev) => ({ ...prev, [id]: nextMessages.length < INITIAL_SESSION_MESSAGE_LIMIT })); - } catch (error) { - sessionWarn("session.ensure.failed", { - sessionID: id, - error: error instanceof Error ? error.message : safeStringify(error), - }); - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [id]: false })); - } - })(); - - ensureInFlightBySession.set(id, run); - try { - await run; - } finally { - if (ensureInFlightBySession.get(id) === run) { - ensureInFlightBySession.delete(id); - } - } - } - - async function selectSession( - sessionID: string, - selectOptions?: { skipHealthCheck?: boolean; source?: string }, - ) { - const c = options.client(); - if (!c) return; - - const perfEnabled = options.developerMode(); - batch(() => { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true })); - options.setSelectedSessionId(sessionID); - options.setError(null); - }); - - const existing = selectInFlightBySession.get(sessionID); - if (existing) { - recordPerfLog(perfEnabled, "session.select", "dedupe join", { - sessionID, - }); - return existing; - } - - const runId = ++selectRunCounter; - const version = ++selectVersion; - const startedAt = perfNow(); - const mark = (event: string, payload?: Record) => { - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - recordPerfLog(perfEnabled, "session.select", event, { - runId, - sessionID, - elapsedMs, - ...(payload ?? {}), - }); - }; - const isStale = () => version !== selectVersion || options.selectedSessionId() !== sessionID; - const abortIfStale = (reason: string) => { - if (!isStale()) return false; - mark(`aborting: ${reason}`); - return true; - }; - - const run = (async () => { - mark("start"); - - const skipHealthCheck = selectOptions?.skipHealthCheck === true; - if (skipHealthCheck) { - mark("health skipped", { source: selectOptions?.source ?? "unknown" }); - } else { - mark("checking health"); - try { - await withTimeout(c.global.health(), 3000, "health"); - mark("health ok"); - } catch (error) { - mark("health FAILED", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - throw new Error(t("app.connection_lost", currentLocale())); - } - } - if (abortIfStale("selection changed after health")) return; - - const existingLimit = messageLimitBySession()[sessionID] ?? 0; - const requestLimit = Math.max(INITIAL_SESSION_MESSAGE_LIMIT, existingLimit); - mark("calling session.messages", { limit: requestLimit }); - const msgs = unwrap( - await withTimeout(c.session.messages({ sessionID, limit: requestLimit }), 12000, "session.messages"), - ); - mark("session.messages done", { limit: requestLimit, count: msgs.length }); - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - if (abortIfStale("selection changed before messages applied")) return; - setMessagesForSession(sessionID, msgs); - setMessageLimitBySession((prev) => ({ ...prev, [sessionID]: requestLimit })); - setMessageCompleteBySession((prev) => ({ ...prev, [sessionID]: msgs.length < requestLimit })); - - const model = options.lastUserModelFromMessages(msgs); - if (model) { - if (abortIfStale("selection changed before model applied")) return; - options.setSessionModelState((current) => ({ - overrides: current.overrides, - resolved: { ...current.resolved, [sessionID]: model }, - })); - - options.setSessionModelState((current) => { - if (!current.overrides[sessionID]) return current; - const copy = { ...current.overrides }; - delete copy[sessionID]; - return { ...current, overrides: copy }; - }); - } - - try { - mark("calling session.todo"); - const list = unwrap(await withTimeout(c.session.todo({ sessionID }), 8000, "session.todo")); - mark("session.todo done"); - if (abortIfStale("selection changed before todos applied")) return; - setStore("todos", sessionID, list as TodoItem[]); - } catch (error) { - mark("session.todo failed/timeout", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - if (abortIfStale("selection changed before todo fallback")) return; - setStore("todos", sessionID, []); - } - - try { - mark("calling permission.list"); - await withTimeout(refreshPendingPermissions(), 6000, "permission.list"); - mark("permission.list done"); - if (abortIfStale("selection changed before permissions applied")) return; - } catch (error) { - mark("permission.list failed/timeout", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - if (abortIfStale("selection changed after permission failure")) return; - } - - finishPerf(perfEnabled, "session.select", "complete", startedAt, { - runId, - sessionID, - messageCount: msgs.length, - todoCount: (store.todos[sessionID] ?? []).length, - }); - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - })(); - - selectInFlightBySession.set(sessionID, run); - try { - await run; - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - if (selectInFlightBySession.get(sessionID) === run) { - selectInFlightBySession.delete(sessionID); - } - } - } - - async function loadEarlierMessages(sessionID: string, chunk = SESSION_MESSAGE_LOAD_CHUNK) { - const c = options.client(); - if (!c) return; - if (!sessionID) return; - if (messageLoadBusyBySession()[sessionID]) return; - if (messageCompleteBySession()[sessionID]) return; - - const currentLimit = Math.max(INITIAL_SESSION_MESSAGE_LIMIT, messageLimitBySession()[sessionID] ?? 0); - const nextLimit = currentLimit + Math.max(1, chunk); - - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: true })); - try { - const msgs = unwrap(await withTimeout(c.session.messages({ sessionID, limit: nextLimit }), 12000, "session.messages")); - setMessagesForSession(sessionID, msgs); - setMessageLimitBySession((prev) => ({ ...prev, [sessionID]: nextLimit })); - setMessageCompleteBySession((prev) => ({ ...prev, [sessionID]: msgs.length < nextLimit })); - } catch (error) { - addError(error); - } finally { - setMessageLoadBusyBySession((prev) => ({ ...prev, [sessionID]: false })); - } - } - - async function respondPermission(requestID: string, reply: "once" | "always" | "reject") { - const c = options.client(); - if (!c || permissionReplyBusy()) return; - - setPermissionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.permission.reply({ requestID, reply })); - await refreshPendingPermissions(); - } catch (e) { - addError(e); - } finally { - setPermissionReplyBusy(false); - } - } - - async function respondQuestion(requestID: string, answers: string[][]) { - const c = options.client(); - if (!c || questionReplyBusy()) return; - - setQuestionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.question.reply({ requestID, answers })); - await refreshPendingQuestions(); - } catch (e) { - addError(e); - } finally { - setQuestionReplyBusy(false); - } - } - - async function rejectQuestion(requestID: string) { - const c = options.client(); - if (!c || questionReplyBusy()) return; - - setQuestionReplyBusy(true); - options.setError(null); - - try { - unwrap(await c.question.reject({ requestID })); - await refreshPendingQuestions(); - } catch (e) { - addError(e); - } finally { - setQuestionReplyBusy(false); - } - } - - const setSessions = (next: Session[]) => { - rememberSessions(next); - setStore("sessions", reconcile(sortSessionsByActivity(next), { key: "id" })); - }; - - const setSessionStatusById = (next: Record) => { - setStore("sessionStatus", next); - }; - - const setMessages = (next: MessageWithParts[]) => { - const id = options.selectedSessionId(); - if (!id) return; - setMessagesForSession(id, next); - }; - - const setTodos = (next: TodoItem[]) => { - const id = options.selectedSessionId(); - if (!id) return; - setStore("todos", id, next); - }; - - const setPendingPermissions = (next: PendingPermission[]) => { - setStore("pendingPermissions", next); - }; - - const setPendingQuestions = (next: PendingQuestion[]) => { - setStore("pendingQuestions", next); - }; - - const activePermission = createMemo(() => { - const id = options.selectedSessionId(); - if (id) { - const scoped = store.pendingPermissions.find((perm) => perm.sessionID === id) ?? null; - if (scoped) return scoped; - } - return store.pendingPermissions[0] ?? null; - }); - - const activeQuestion = createMemo(() => { - const id = options.selectedSessionId(); - if (id) { - const scoped = store.pendingQuestions.find((q) => q.sessionID === id) ?? null; - if (scoped) return scoped; - } - return store.pendingQuestions[0] ?? null; - }); - - const [questionReplyBusy, setQuestionReplyBusy] = createSignal(false); - let lastPartDebugEventAt = 0; - let suppressedPartDebugEvents = 0; - - const appendDebugEvent = (event: { type: string; properties?: unknown }) => { - setStore("events", (current) => { - const next = [event, ...current]; - return next.slice(0, 150); - }); - }; - - const setSessionCompaction = (sessionID: string, next: SessionCompactionState) => { - setStore("sessionCompaction", sessionID, next); - }; - - const stopSessionCompaction = (sessionID: string) => { - const current = store.sessionCompaction[sessionID]; - pendingCompactionModeBySession.delete(sessionID); - if (!current?.running) return; - setSessionCompaction(sessionID, { - ...current, - running: false, - messageID: null, - }); - }; - - const startSessionCompaction = (sessionID: string, messageID: string) => { - const current = store.sessionCompaction[sessionID]; - if (current?.running && current.messageID === messageID) return; - const startedAt = Date.now(); - const mode = pendingCompactionModeBySession.get(sessionID) ?? current?.mode ?? null; - pendingCompactionModeBySession.delete(sessionID); - setSessionCompaction(sessionID, { - running: true, - startedAt, - finishedAt: null, - mode, - messageID, - }); - if (options.developerMode()) { - appendDebugEvent({ - type: "session.compaction.started", - properties: { sessionID, messageID, mode, startedAt }, - }); - } - }; - - const finishSessionCompaction = (sessionID: string) => { - const current = store.sessionCompaction[sessionID]; - const finishedAt = Date.now(); - pendingCompactionModeBySession.delete(sessionID); - setSessionCompaction(sessionID, { - running: false, - startedAt: current?.startedAt ?? null, - finishedAt, - mode: current?.mode ?? null, - messageID: null, - }); - if (options.developerMode()) { - appendDebugEvent({ - type: "session.compaction.finished", - properties: { - sessionID, - mode: current?.mode ?? null, - startedAt: current?.startedAt ?? null, - finishedAt, - durationMs: - typeof current?.startedAt === "number" ? Math.max(0, finishedAt - current.startedAt) : null, - }, - }); - } - }; - - const compactDebugEvent = (event: OpencodeEvent) => { - if (event.type === "message.part.updated") { - const record = event.properties as Record | undefined; - const part = record?.part as Part | undefined; - const delta = typeof record?.delta === "string" ? record.delta : ""; - const textLength = - part?.type === "text" && typeof (part as { text?: unknown }).text === "string" - ? String((part as { text?: string }).text).length - : null; - return { - type: event.type, - properties: { - sessionID: part?.sessionID ?? null, - messageID: part?.messageID ?? null, - partID: part?.id ?? null, - partType: part?.type ?? null, - deltaLength: delta.length, - textLength, - }, - }; - } - - if (event.type === "message.part.delta") { - const record = event.properties as Record | undefined; - const delta = typeof record?.delta === "string" ? record.delta : ""; - return { - type: event.type, - properties: { - sessionID: typeof record?.sessionID === "string" ? record.sessionID : null, - messageID: typeof record?.messageID === "string" ? record.messageID : null, - partID: typeof record?.partID === "string" ? record.partID : null, - field: typeof record?.field === "string" ? record.field : null, - deltaLength: delta.length, - }, - }; - } - - return { - type: event.type, - properties: event.properties, - }; - }; - - const applyEvent = async (event: OpencodeEvent) => { - if (event.type === "server.connected") { - options.setSseConnected(true); - } - - if (options.developerMode()) { - const compact = compactDebugEvent(event); - if (event.type === "message.part.updated" || event.type === "message.part.delta") { - const now = Date.now(); - if (now - lastPartDebugEventAt < 250) { - suppressedPartDebugEvents += 1; - } else { - lastPartDebugEventAt = now; - if (suppressedPartDebugEvents > 0) { - compact.properties = { - ...(compact.properties ?? {}), - suppressed: suppressedPartDebugEvents, - }; - suppressedPartDebugEvents = 0; - } - appendDebugEvent(compact); - } - } else { - if (suppressedPartDebugEvents > 0) { - appendDebugEvent({ - type: "message.part.stream.sample", - properties: { suppressed: suppressedPartDebugEvents }, - }); - suppressedPartDebugEvents = 0; - } - appendDebugEvent(compact); - } - } - - if (event.type === "session.updated" || event.type === "session.created") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.info && typeof record.info === "object") { - const info = record.info as Session; - rememberSession(info); - setStore("sessions", (current) => upsertSession(current, info)); - } - } - } - - if (event.type === "session.deleted") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const info = record.info as Session | undefined; - if (info?.id) { - syntheticContinueEventTimesBySession.delete(info.id); - syntheticTaskSummaryEventTimesBySession.delete(info.id); - syntheticContinueLoopLastWarnAtBySession.delete(info.id); - syntheticLoopLastAbortAtByKey.delete(`task-summary:${info.id}`); - syntheticLoopLastAbortAtByKey.delete(`compaction-continue:${info.id}`); - pendingCompactionModeBySession.delete(info.id); - setStore( - produce((draft: StoreState) => { - delete draft.sessionInfoById[info.id]; - delete draft.sessionCompaction[info.id]; - }), - ); - setStore("sessions", (current) => removeSession(current, info.id)); - setStore( - produce((draft: StoreState) => { - delete draft.sessionErrorTurns[info.id]; - }), - ); - } - } - } - - if (event.type === "session.status") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - const normalized = normalizeSessionStatus(record.status); - setStore("sessionStatus", sessionID, normalized); - if (normalized === "idle") { - stopSessionCompaction(sessionID); - } - if (sessionID === options.selectedSessionId() && normalized !== "idle") { - options.setError(null); - } - } - } - } - - if (event.type === "session.idle") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - setStore("sessionStatus", sessionID, "idle"); - stopSessionCompaction(sessionID); - const c = options.client(); - if (c) { - try { - const latest = unwrap(await c.session.get({ sessionID })); - rememberSession(latest); - setStore("sessions", (current) => upsertSession(current, latest)); - } catch { - // ignore - } - } - } - } - } - - if (event.type === "opencode.hotreload.applied") { - options.onHotReloadApplied?.(); - } - - if (event.type === "session.error") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - setStore("sessionStatus", sessionID, "idle"); - stopSessionCompaction(sessionID); - } - const errorObj = record.error as Record | undefined; - if (errorObj) { - const errorName = typeof errorObj.name === "string" ? errorObj.name : "UnknownError"; - if (errorName === "MessageAbortedError") { - // Cancellation is a user-driven control flow. Don't treat it as a - // fatal error banner; the session UI already provides local UX. - if (!sessionID) { - options.setError(null); - } - return; - } - if (sessionID) { - appendSessionErrorTurn(sessionID, addOpencodeCacheHint(formatSessionError(errorObj))); - } else { - options.setError(addOpencodeCacheHint(formatSessionError(errorObj))); - } - return; - } - - const fallback = truncateErrorField(record.error, 700) ?? "An unexpected error occurred"; - if (sessionID) { - appendSessionErrorTurn(sessionID, addOpencodeCacheHint(fallback)); - } else { - options.setError(addOpencodeCacheHint(fallback)); - } - } - } - - if (event.type === "message.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.info && typeof record.info === "object") { - const info = record.info as Message; - const messageRecord = info as Message & Record; - const model = modelFromUserMessage(info as MessageInfo); - if (model) { - options.setSessionModelState((current) => ({ - overrides: current.overrides, - resolved: { ...current.resolved, [info.sessionID]: model }, - })); - - options.setSessionModelState((current) => { - if (!current.overrides[info.sessionID]) return current; - const copy = { ...current.overrides }; - delete copy[info.sessionID]; - return { ...current, overrides: copy }; - }); - } - - setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info)); - - if ( - messageRecord.role === "assistant" && - messageRecord.mode === "compaction" && - messageRecord.summary === true - ) { - startSessionCompaction(info.sessionID, info.id); - } - } - } - } - - if (event.type === "message.removed") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - if (sessionID && messageID) { - setStore("messages", sessionID, (current = []) => removeMessageInfo(current, messageID)); - setStore("parts", messageID, []); - } - } - } - - if (event.type === "message.part.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - if (record.part && typeof record.part === "object") { - const part = record.part as Part; - const delta = typeof record.delta === "string" ? record.delta : null; - const partUpdatedStartedAt = perfNow(); - - if (part.type === "compaction") { - pendingCompactionModeBySession.set( - part.sessionID, - (part as Part & { auto?: unknown }).auto === true ? "auto" : "manual", - ); - } - - setStore( - produce((draft: StoreState) => { - const list = draft.messages[part.sessionID] ?? []; - if (!list.find((message) => message.id === part.messageID)) { - draft.messages[part.sessionID] = upsertMessageInfo(list, createPlaceholderMessage(part)); - } - - const parts = draft.parts[part.messageID] ?? []; - const existingIndex = parts.findIndex((item) => item.id === part.id); - - if (delta && part.type === "text" && existingIndex !== -1) { - const existing = parts[existingIndex] as Part & { text?: string }; - if (typeof existing.text === "string" && !existing.text.endsWith(delta)) { - const next = { ...existing, text: `${existing.text}${delta}` } as Part; - parts[existingIndex] = next; - draft.parts[part.messageID] = parts; - return; - } - } - - draft.parts[part.messageID] = upsertPartInfo(parts, part); - }), - ); - const resolvedPart = - store.parts[part.messageID]?.find((item) => item.id === part.id) ?? - part; - recordSyntheticContinueDiagnostic(resolvedPart); - recordSyntheticTaskSummaryDiagnostic(resolvedPart); - maybeAbortSyntheticControlLoop(resolvedPart); - const partUpdatedMs = Math.round((perfNow() - partUpdatedStartedAt) * 100) / 100; - if (sessionDebugEnabled() && (partUpdatedMs >= 8 || (delta?.length ?? 0) >= 120)) { - const textLength = - part.type === "text" && typeof (part as { text?: unknown }).text === "string" - ? String((part as { text?: string }).text).length - : null; - recordPerfLog(true, "session.event", "message.part.updated", { - sessionID: part.sessionID, - messageID: part.messageID, - partID: part.id, - partType: part.type, - deltaLength: delta?.length ?? 0, - textLength, - ms: partUpdatedMs, - }); - } - maybeMarkReloadRequired(part); - maybeHandleInvalidToolError(part); - } - } - } - - if (event.type === "message.part.delta") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - const partID = typeof record.partID === "string" ? record.partID : null; - const field = typeof record.field === "string" ? record.field : null; - const delta = typeof record.delta === "string" ? record.delta : null; - const partDeltaStartedAt = perfNow(); - - if (messageID && partID && field && delta) { - setStore("parts", messageID, (current = []) => appendPartDelta(current, messageID, sessionID, partID, field, delta)); - const partDeltaMs = Math.round((perfNow() - partDeltaStartedAt) * 100) / 100; - if (sessionDebugEnabled() && (partDeltaMs >= 8 || delta.length >= 120)) { - recordPerfLog(true, "session.event", "message.part.delta", { - sessionID, - messageID, - partID, - field, - deltaLength: delta.length, - ms: partDeltaMs, - }); - } - } - } - } - - if (event.type === "message.part.removed") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const messageID = typeof record.messageID === "string" ? record.messageID : null; - const partID = typeof record.partID === "string" ? record.partID : null; - if (messageID && partID) { - setStore("parts", messageID, (current = []) => removePartInfo(current, partID)); - } - } - } - - if (event.type === "todo.updated") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID && Array.isArray(record.todos)) { - setStore("todos", sessionID, record.todos as TodoItem[]); - } - } - } - - if (event.type === "session.compacted") { - if (event.properties && typeof event.properties === "object") { - const record = event.properties as Record; - const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; - if (sessionID) { - finishSessionCompaction(sessionID); - } - } - } - - if (event.type === "permission.asked" || event.type === "permission.replied") { - try { - await refreshPendingPermissions(); - } catch { - // ignore - } - } - - if ( - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" - ) { - try { - await refreshPendingQuestions(); - } catch { - // ignore - } - } - }; - - createEffect(() => { - const c = options.client(); - if (!c) return; - - let cancelled = false; - let reconnectAttempt = 0; - let reconnectTimer: ReturnType | undefined; - - let queue: Array = []; - const coalesced = new Map(); - let timer: ReturnType | undefined; - let last = 0; - let queueStartedAt = 0; - let peakQueueDepth = 0; - let queueHasPartUpdates = false; - let coalescedReplaced = 0; - - const keyForEvent = (event: OpencodeEvent) => { - if (event.type === "session.status" || event.type === "session.idle") { - const record = event.properties as Record | undefined; - const sessionID = typeof record?.sessionID === "string" ? record.sessionID : ""; - return sessionID ? `${event.type}:${sessionID}` : undefined; - } - if (event.type === "message.part.updated") { - const record = event.properties as Record | undefined; - const part = record?.part as Part | undefined; - if (part?.messageID && part.id) { - return `message.part.updated:${part.messageID}:${part.id}`; - } - } - if (event.type === "todo.updated") { - const record = event.properties as Record | undefined; - const sessionID = typeof record?.sessionID === "string" ? record.sessionID : ""; - return sessionID ? `todo.updated:${sessionID}` : undefined; - } - return undefined; - }; - - const flush = () => { - if (timer) clearTimeout(timer); - timer = undefined; - - const eventsToApply = queue; - queue = []; - coalesced.clear(); - if (eventsToApply.length === 0) return; - - const queueWaitMs = queueStartedAt > 0 ? Date.now() - queueStartedAt : 0; - queueStartedAt = 0; - const peakDepth = peakQueueDepth; - peakQueueDepth = 0; - queueHasPartUpdates = false; - const replaced = coalescedReplaced; - coalescedReplaced = 0; - - last = Date.now(); - const startedAt = perfNow(); - let applied = 0; - let partUpdates = 0; - let messageUpdates = 0; - batch(() => { - for (const event of eventsToApply) { - if (!event) continue; - if (event.type === "message.part.updated" || event.type === "message.part.delta") partUpdates += 1; - if (event.type === "message.updated") messageUpdates += 1; - applied += 1; - void applyEvent(event); - } - }); - - const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; - const dropped = eventsToApply.length - applied; - if ( - sessionDebugEnabled() && - (elapsedMs >= 10 || queueWaitMs >= 40 || peakDepth >= 25 || applied >= 30 || dropped >= 12) - ) { - recordPerfLog(true, "session.sse", "flush", { - queued: eventsToApply.length, - applied, - dropped, - queueWaitMs, - peakQueueDepth: peakDepth, - coalescedReplaced: replaced, - messageUpdates, - partUpdates, - ms: elapsedMs, - }); - } - }; - - const schedule = () => { - if (timer) return; - const elapsed = Date.now() - last; - const interval = queueHasPartUpdates ? 48 : 16; - timer = setTimeout(flush, Math.max(0, interval - elapsed)); - }; - - const connectSse = async (controller: AbortController) => { - try { - const sub = await c.event.subscribe(undefined, { signal: controller.signal }); - let yielded = Date.now(); - let lastArrivalAt = Date.now(); - - // Reset reconnect counter on successful connection - reconnectAttempt = 0; - recordPerfLog(sessionDebugEnabled(), "session.sse", "connected"); - - for await (const raw of sub.stream) { - if (cancelled) break; - - const event = normalizeEvent(raw); - if (!event) continue; - - const arrivedAt = Date.now(); - const arrivalGapMs = arrivedAt - lastArrivalAt; - lastArrivalAt = arrivedAt; - if (sessionDebugEnabled() && arrivalGapMs >= 220) { - recordPerfLog(true, "session.sse", "arrival-gap", { - ms: arrivalGapMs, - type: event.type, - }); - } - - const key = keyForEvent(event); - if (key) { - const existing = coalesced.get(key); - if (existing !== undefined) { - if (queue[existing] !== undefined) { - coalescedReplaced += 1; - } - queue[existing] = undefined; - } - coalesced.set(key, queue.length); - } - - if (queue.length === 0) { - queueStartedAt = Date.now(); - } - if (event.type === "message.part.updated" || event.type === "message.part.delta") { - queueHasPartUpdates = true; - } - queue.push(event); - if (queue.length > peakQueueDepth) { - peakQueueDepth = queue.length; - } - schedule(); - - if (Date.now() - yielded < 8) continue; - yielded = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - // Stream ended normally - attempt reconnect unless cancelled - if (!cancelled) { - options.setSseConnected(false); - recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-ended"); - scheduleReconnect(controller); - } - } catch (e) { - if (cancelled) return; - - const message = e instanceof Error ? e.message : String(e); - if (message.toLowerCase().includes("abort")) return; - - // Mark SSE as disconnected and schedule reconnect - options.setSseConnected(false); - recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-error", { - error: message, - }); - scheduleReconnect(controller); - } - }; - - const scheduleReconnect = (oldController: AbortController) => { - if (cancelled) return; - oldController.abort(); - - // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s - reconnectAttempt++; - const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), 30000); - recordPerfLog(sessionDebugEnabled(), "session.sse", "reconnect-scheduled", { - attempt: reconnectAttempt, - delayMs: delay, - }); - - reconnectTimer = setTimeout(() => { - if (cancelled) return; - const newController = new AbortController(); - void connectSse(newController); - }, delay); - }; - - const controller = new AbortController(); - void connectSse(controller); - - onCleanup(() => { - cancelled = true; - controller.abort(); - if (reconnectTimer) clearTimeout(reconnectTimer); - flush(); - }); - }); - - return { - sessions, - loadedScopeRoot, - sessionById, - sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []), - selectedSessionErrorTurns: createMemo(() => { - const sessionID = options.selectedSessionId(); - return sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []; - }), - sessionStatusById, - selectedSession, - selectedSessionStatus, - messageIdFromInfo, - visibleMessages, - blueprintSeedMessagesForSelectedSession, - restorePromptFromUserMessage, - upsertLocalSession, - setBlueprintSeedMessagesBySessionId, - selectedSessionCompactionState: createMemo(() => { - const sessionID = options.selectedSessionId(); - return sessionID ? store.sessionCompaction[sessionID] ?? null : null; - }), - messages, - messagesBySessionId, - sessionCompactionById: (sessionID: string | null) => (sessionID ? store.sessionCompaction[sessionID] ?? null : null), - todos, - pendingPermissions, - permissionReplyBusy, - pendingQuestions, - activeQuestion, - questionReplyBusy, - events, - activePermission, - loadSessions, - ensureSessionLoaded, - refreshPendingPermissions, - refreshPendingQuestions, - selectSession, - loadEarlierMessages, - renameSession, - respondPermission, - respondQuestion, - rejectQuestion, - appendSessionErrorTurn, - setSessions, - setSessionStatusById, - setMessages, - setTodos, - setPendingPermissions, - setPendingQuestions, - selectedSessionHasEarlierMessages, - selectedSessionLoadingEarlierMessages, - sessionLoadingById: (sessionID: string | null) => (sessionID ? Boolean(messageLoadBusyBySession()[sessionID]) : false), - }; -} diff --git a/apps/app/src/app/context/sidebar-sessions.ts b/apps/app/src/app/context/sidebar-sessions.ts deleted file mode 100644 index 372d9880..00000000 --- a/apps/app/src/app/context/sidebar-sessions.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { createEffect, createMemo, createSignal } from "solid-js"; - -import type { Session } from "@opencode-ai/sdk/v2/client"; - -import { createClient, type OpencodeAuth, unwrap } from "../lib/opencode"; -import type { WorkspaceInfo, EngineInfo } from "../lib/tauri"; -import type { SidebarSessionItem, WorkspaceSessionGroup } from "../types"; -import { - normalizeDirectoryPath, - safeStringify, -} from "../utils"; -import { toSessionTransportDirectory } from "../lib/session-scope"; - -const sessionActivity = (session: Session) => - session.time?.updated ?? session.time?.created ?? 0; - -const sortSessionsByActivity = (list: Session[]) => - list - .slice() - .sort((a, b) => { - const delta = sessionActivity(b) - sessionActivity(a); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - -type SidebarWorkspaceSessionsStatus = WorkspaceSessionGroup["status"]; - -export function createSidebarSessionsStore(options: { - workspaces: () => WorkspaceInfo[]; - engine: () => EngineInfo | null; -}) { - const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = createSignal< - Record - >({}); - const [statusByWorkspaceId, setStatusByWorkspaceId] = createSignal< - Record - >({}); - const [errorByWorkspaceId, setErrorByWorkspaceId] = createSignal>({}); - - const pruneState = (workspaceIds: Set) => { - setSessionsByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, list] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = list; - } - return changed ? next : prev; - }); - setStatusByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, status] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = status; - } - return changed ? next : prev; - }); - setErrorByWorkspaceId((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, error] of Object.entries(prev)) { - if (!workspaceIds.has(id)) { - changed = true; - continue; - } - next[id] = error; - } - return changed ? next : prev; - }); - }; - - const resolveClientConfig = (workspaceId: string) => { - const workspace = options.workspaces().find((entry) => entry.id === workspaceId) ?? null; - if (!workspace) return null; - - if (workspace.workspaceType === "local") { - const info = options.engine(); - const baseUrl = info?.baseUrl?.trim() ?? ""; - const directory = toSessionTransportDirectory(workspace.path?.trim() ?? ""); - const username = info?.opencodeUsername?.trim() ?? ""; - const password = info?.opencodePassword?.trim() ?? ""; - const auth: OpencodeAuth | undefined = username && password ? { username, password } : undefined; - return { baseUrl, directory, auth }; - } - - const baseUrl = workspace.baseUrl?.trim() ?? ""; - const directory = workspace.directory?.trim() ?? ""; - if (workspace.remoteType === "openwork") { - const token = workspace.openworkToken?.trim() ?? ""; - const auth: OpencodeAuth | undefined = token ? { token, mode: "openwork" } : undefined; - return { baseUrl, directory, auth }; - } - - return { - baseUrl, - directory, - auth: undefined as OpencodeAuth | undefined, - }; - }; - - const refreshSeqByWorkspaceId: Record = {}; - const SIDEBAR_SESSION_LIMIT = 200; - - const refreshWorkspaceSessions = async (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - - const config = resolveClientConfig(id); - if (!config) return; - - if (!config.baseUrl) { - setStatusByWorkspaceId((prev) => (prev[id] === "idle" ? prev : { ...prev, [id]: "idle" })); - setErrorByWorkspaceId((prev) => ((prev[id] ?? null) === null ? prev : { ...prev, [id]: null })); - return; - } - - refreshSeqByWorkspaceId[id] = (refreshSeqByWorkspaceId[id] ?? 0) + 1; - const seq = refreshSeqByWorkspaceId[id]; - - setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "loading" })); - setErrorByWorkspaceId((prev) => ({ ...prev, [id]: null })); - - try { - let directory = config.directory; - let client = createClient(config.baseUrl, directory || undefined, config.auth); - - if (!directory) { - try { - const pathInfo = unwrap(await client.path.get()); - const discovered = toSessionTransportDirectory(pathInfo.directory ?? ""); - if (discovered) { - directory = discovered; - client = createClient(config.baseUrl, directory, config.auth); - } - } catch { - // Ignore discovery failures and continue with the configured directory. - } - } - - const queryDirectory = toSessionTransportDirectory(directory) || undefined; - const list = unwrap( - await client.session.list({ directory: queryDirectory, roots: false, limit: SIDEBAR_SESSION_LIMIT }), - ); - if (refreshSeqByWorkspaceId[id] !== seq) return; - - const root = normalizeDirectoryPath(directory); - const filtered = root ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) : list; - const sorted = sortSessionsByActivity(filtered); - const items: SidebarSessionItem[] = sorted.map((session) => ({ - id: session.id, - title: session.title, - slug: session.slug, - parentID: session.parentID, - time: session.time, - directory: session.directory, - })); - - setSessionsByWorkspaceId((prev) => ({ - ...prev, - [id]: items, - })); - setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "ready" })); - } catch (error) { - if (refreshSeqByWorkspaceId[id] !== seq) return; - const message = error instanceof Error ? error.message : safeStringify(error); - setStatusByWorkspaceId((prev) => ({ ...prev, [id]: "error" })); - setErrorByWorkspaceId((prev) => ({ ...prev, [id]: message })); - } - }; - - let lastFingerprintByWorkspaceId: Record = {}; - createEffect(() => { - const engineInfo = options.engine(); - const engineBaseUrl = engineInfo?.baseUrl?.trim() ?? ""; - const engineUser = engineInfo?.opencodeUsername?.trim() ?? ""; - const enginePass = engineInfo?.opencodePassword?.trim() ?? ""; - const workspaces = options.workspaces(); - const workspaceIds = new Set(workspaces.map((workspace) => workspace.id)); - pruneState(workspaceIds); - - const nextFingerprintByWorkspaceId: Record = {}; - for (const workspace of workspaces) { - const root = workspace.workspaceType === "local" ? workspace.path?.trim() ?? "" : workspace.directory?.trim() ?? ""; - const base = workspace.workspaceType === "local" ? engineBaseUrl : workspace.baseUrl?.trim() ?? ""; - const remoteType = workspace.workspaceType === "remote" ? (workspace.remoteType ?? "") : ""; - const token = workspace.remoteType === "openwork" ? (workspace.openworkToken?.trim() ?? "") : ""; - const authKey = workspace.workspaceType === "local" ? `${engineUser}:${enginePass}` : token; - nextFingerprintByWorkspaceId[workspace.id] = [workspace.workspaceType, remoteType, root, base, authKey].join("|"); - } - - for (const workspace of workspaces) { - const nextFingerprint = nextFingerprintByWorkspaceId[workspace.id]; - if (lastFingerprintByWorkspaceId[workspace.id] === nextFingerprint) continue; - void refreshWorkspaceSessions(workspace.id).catch(() => undefined); - } - - lastFingerprintByWorkspaceId = nextFingerprintByWorkspaceId; - }); - - const workspaceGroups = createMemo(() => { - const workspaces = options.workspaces(); - const sessions = sessionsByWorkspaceId(); - const statuses = statusByWorkspaceId(); - const errors = errorByWorkspaceId(); - return workspaces.map((workspace) => ({ - workspace, - sessions: sessions[workspace.id] ?? [], - status: statuses[workspace.id] ?? "idle", - error: errors[workspace.id] ?? null, - })); - }); - - return { - workspaceGroups, - refreshWorkspaceSessions, - }; -} diff --git a/apps/app/src/app/context/updater.ts b/apps/app/src/app/context/updater.ts deleted file mode 100644 index 54c88591..00000000 --- a/apps/app/src/app/context/updater.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSignal } from "solid-js"; - -import type { UpdateHandle } from "../types"; -import type { UpdaterEnvironment } from "../lib/tauri"; - -export type UpdateStatus = - | { state: "idle"; lastCheckedAt: number | null } - | { state: "checking"; startedAt: number } - | { state: "available"; lastCheckedAt: number; version: string; date?: string; notes?: string } - | { - state: "downloading"; - lastCheckedAt: number; - version: string; - totalBytes: number | null; - downloadedBytes: number; - notes?: string; - } - | { state: "ready"; lastCheckedAt: number; version: string; notes?: string } - | { state: "error"; lastCheckedAt: number | null; message: string }; - -export type PendingUpdate = { update: UpdateHandle; version: string; notes?: string } | null; - -export function createUpdaterState() { - const [updateAutoCheck, setUpdateAutoCheck] = createSignal(true); - const [updateAutoDownload, setUpdateAutoDownload] = createSignal(false); - const [updateStatus, setUpdateStatus] = createSignal({ state: "idle", lastCheckedAt: null }); - const [pendingUpdate, setPendingUpdate] = createSignal(null); - const [updateEnv, setUpdateEnv] = createSignal(null); - - return { - updateAutoCheck, - setUpdateAutoCheck, - updateAutoDownload, - setUpdateAutoDownload, - updateStatus, - setUpdateStatus, - pendingUpdate, - setPendingUpdate, - updateEnv, - setUpdateEnv, - } as const; -} diff --git a/apps/app/src/app/context/workspace-context.ts b/apps/app/src/app/context/workspace-context.ts deleted file mode 100644 index 2f187348..00000000 --- a/apps/app/src/app/context/workspace-context.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createMemo } from "solid-js"; - -import { normalizeDirectoryPath } from "../utils"; - -export function createWorkspaceContextKey(options: { - selectedWorkspaceId: () => string; - selectedWorkspaceRoot: () => string; - runtimeWorkspaceId?: () => string | null; - workspaceType?: () => "local" | "remote"; -}) { - return createMemo(() => { - const workspaceId = options.selectedWorkspaceId().trim(); - const root = normalizeDirectoryPath(options.selectedWorkspaceRoot().trim()); - const runtimeWorkspaceId = (options.runtimeWorkspaceId?.() ?? "").trim(); - const workspaceType = options.workspaceType?.() ?? "local"; - return `${workspaceType}:${workspaceId}:${root}:${runtimeWorkspaceId}`; - }); -} diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts deleted file mode 100644 index 01c39957..00000000 --- a/apps/app/src/app/context/workspace.ts +++ /dev/null @@ -1,4151 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; -import { listen, type Event as TauriEvent } from "@tauri-apps/api/event"; -import type { Session } from "@opencode-ai/sdk/v2/client"; - -import type { - Client, - StartupPreference, - OnboardingStep, - WorkspaceDisplay, - WorkspaceOpenworkConfig, - WorkspacePreset, - WorkspaceConnectionState, - EngineRuntime, -} from "../types"; -import { - addOpencodeCacheHint, - clearStartupPreference, - isTauriRuntime, - normalizeDirectoryPath, - readStartupPreference, - safeStringify, - writeStartupPreference, -} from "../utils"; -import { unwrap } from "../lib/opencode"; -import { recordDevLog } from "../lib/dev-log"; -import { describeDirectoryScope, resolveScopedClientDirectory, toSessionTransportDirectory } from "../lib/session-scope"; -import { blueprintMaterializedSessions, blueprintSessions, defaultBlueprintSessionsForPreset } from "../lib/workspace-blueprints"; -import { - buildOpenworkWorkspaceBaseUrl, - createOpenworkServerClient, - normalizeOpenworkServerUrl, - parseOpenworkWorkspaceIdFromUrl, - OpenworkServerError, - type OpenworkServerClient, - type OpenworkWorkspaceInfo, -} from "../lib/openwork-server"; -import { downloadDir, homeDir } from "@tauri-apps/api/path"; -import { - engineDoctor, - engineInfo, - engineInstall, - engineStart, - engineStop, - sandboxDoctor, - orchestratorInstanceDispose, - orchestratorStartDetached, - orchestratorWorkspaceActivate, - pickFile, - pickDirectory, - saveFile, - workspaceBootstrap, - workspaceCreate, - workspaceCreateRemote, - workspaceExportConfig, - workspaceForget, - workspaceImportConfig, - workspaceOpenworkRead, - workspaceOpenworkWrite, - workspaceSetRuntimeActive, - workspaceSetSelected, - workspaceUpdateDisplayName, - workspaceUpdateRemote, - resolveWorkspaceListSelectedId, - type EngineDoctorResult, - type EngineInfo, - type SandboxDoctorResult, - type WorkspaceInfo, -} from "../lib/tauri"; -import type { BootPhase, StartupBranch } from "../lib/startup-boot"; -import { waitForHealthy, createClient, type OpencodeAuth } from "../lib/opencode"; -import type { OpencodeConnectStatus, ProviderListItem } from "../types"; -import { t, currentLocale } from "../../i18n"; -import { filterProviderList, mapConfigProvidersToList } from "../utils/providers"; -import { buildDefaultWorkspaceBlueprint, normalizeWorkspaceOpenworkConfig } from "../lib/workspace-blueprints"; -import type { OpenworkServerStore } from "../connections/openwork-server-store"; -import { resolveSandboxCreateMode, type SandboxBackendType } from "./sandbox-create-mode"; - -export type WorkspaceStore = ReturnType; - -export type WorkspaceDebugEvent = { - at: number; - label: string; - payload?: unknown; -}; - -export type SandboxCreateProgressStepStatus = "pending" | "active" | "done" | "error"; - -export type SandboxCreateProgressStep = { - key: "docker" | "workspace" | "sandbox" | "health" | "connect"; - label: string; - status: SandboxCreateProgressStepStatus; - detail?: string | null; -}; - -export type SandboxCreateProgressState = { - runId: string; - startedAt: number; - stage: string; - steps: SandboxCreateProgressStep[]; - logs: string[]; - error: string | null; -}; - -export type SandboxCreatePhase = "idle" | "preflight" | "provisioning" | "finalizing"; - -type BlueprintSeedMessage = { role?: "assistant" | "user" | null; text?: string | null }; - -type RuntimeWorkspaceLookup = { - workspaceId?: string | null; - directoryHint?: string | null; - localRoot?: string | null; - strictMatch?: boolean; -}; - -export function createWorkspaceStore(options: { - startupPreference: () => StartupPreference | null; - setStartupPreference: (value: StartupPreference | null) => void; - onboardingStep: () => OnboardingStep; - setOnboardingStep: (step: OnboardingStep) => void; - rememberStartupChoice: () => boolean; - setRememberStartupChoice: (value: boolean) => void; - baseUrl: () => string; - setBaseUrl: (value: string) => void; - clientDirectory: () => string; - setClientDirectory: (value: string) => void; - client: () => Client | null; - setClient: (value: Client | null) => void; - setConnectedVersion: (value: string | null) => void; - setSseConnected: (value: boolean) => void; - setProviders: (value: ProviderListItem[]) => void; - setProviderDefaults: (value: Record) => void; - setProviderConnectedIds: (value: string[]) => void; - setError: (value: string | null) => void; - setBusy: (value: boolean) => void; - setBusyLabel: (value: string | null) => void; - setBusyStartedAt: (value: number | null) => void; - loadSessions: (scopeRoot?: string) => Promise; - refreshPendingPermissions: () => Promise; - refreshWorkspaceSessions?: (workspaceId: string) => Promise; - sessions: () => Session[]; - sessionsLoaded: () => boolean; - creatingSession: () => boolean; - readLastSessionByWorkspace?: () => Record; - selectedSessionId: () => string | null; - selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise; - setBlueprintSeedMessagesBySessionId: ( - updater: (current: Record) => Record, - ) => void; - setSelectedSessionId: (value: string | null) => void; - setMessages: (value: any[]) => void; - setTodos: (value: any[]) => void; - setPendingPermissions: (value: any[]) => void; - setSessionStatusById: (value: Record) => void; - defaultModel: () => any; - modelVariant: () => string | null; - refreshSkills: (options?: { force?: boolean }) => Promise; - refreshPlugins: () => Promise; - engineSource: () => "path" | "sidecar" | "custom"; - engineCustomBinPath?: () => string; - opencodeEnableExa?: () => boolean; - setEngineSource: (value: "path" | "sidecar" | "custom") => void; - setView: (value: any, sessionId?: string) => void; - setSettingsTab: (value: any) => void; - isWindowsPlatform: () => boolean; - openworkServer: OpenworkServerStore; - openworkEnvWorkspaceId?: string | null; - setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void; - onEngineStable?: () => void; - onBootPhaseChange?: (phase: BootPhase, detail?: Record) => void; - onStartupBranch?: (branch: StartupBranch, detail?: Record) => void; - onStartupTrace?: (event: string, detail?: Record) => void; - engineRuntime?: () => EngineRuntime; - developerMode: () => boolean; - pendingInitialSessionSelection?: () => { workspaceId: string; title: string | null; readyAt: number } | null; - setPendingInitialSessionSelection?: (input: { workspaceId: string; title: string | null; readyAt: number } | null) => void; - useMicrosandboxCreateSandbox?: () => boolean; -}) { - - const wsDebugEnabled = () => options.developerMode(); - - const WORKSPACE_DEBUG_EVENT_LIMIT = 200; - const [workspaceDebugEvents, setWorkspaceDebugEvents] = createSignal([]); - const clearWorkspaceDebugEvents = () => setWorkspaceDebugEvents([]); - const pushWorkspaceDebugEvent = (label: string, payload?: unknown) => { - if (!wsDebugEnabled()) return; - const entry: WorkspaceDebugEvent = { at: Date.now(), label, payload }; - setWorkspaceDebugEvents((prev) => { - if (!prev.length) return [entry]; - const sliceStart = Math.max(0, prev.length - WORKSPACE_DEBUG_EVENT_LIMIT + 1); - const next = prev.slice(sliceStart); - next.push(entry); - return next; - }); - }; - - const wsDebug = (label: string, payload?: unknown) => { - if (!wsDebugEnabled()) return; - try { - recordDevLog(true, { level: "debug", source: "workspace", label, payload }); - if (payload === undefined) { - console.log(`[WSDBG] ${label}`); - } else { - console.log(`[WSDBG] ${label}`, payload); - } - pushWorkspaceDebugEvent(label, payload); - } catch { - // ignore - } - }; - - const connectInFlightByKey = new Map>(); - let createRemoteInFlight: Promise | null = null; - const DEFAULT_CONNECT_HEALTH_TIMEOUT_MS = 12_000; - const LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS = 180_000; - const LONG_BOOT_CONNECT_REASONS = new Set(["host-start", "bootstrap-local"]); - const DEFAULT_WORKSPACE_HOME_FOLDER_NAME = "OpenWork"; - const FIRST_RUN_WELCOME_WORKSPACE_NAME = "Welcome"; - const preferredInitialSessionTitleForPreset = (preset: WorkspacePreset) => { - const trimmed = defaultBlueprintSessionsForPreset(preset) - .find((session) => session.openOnFirstLoad === true)?.title?.trim(); - return trimmed || null; - }; - - const queuePendingInitialSessionSelection = (workspaceId: string | null, preset: WorkspacePreset) => { - const preferredInitialSessionTitle = preferredInitialSessionTitleForPreset(preset); - if (!workspaceId) { - options.setPendingInitialSessionSelection?.(null); - return; - } - options.setPendingInitialSessionSelection?.({ - workspaceId, - title: preferredInitialSessionTitle, - readyAt: Date.now() + 2_000, - }); - }; - - const connectRequestKey = ( - nextBaseUrl: string, - directory?: string, - context?: { - workspaceId?: string; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string; - reason?: string; - }, - auth?: OpencodeAuth, - connectOptions?: { quiet?: boolean; navigate?: boolean }, - ) => - [ - nextBaseUrl.trim(), - (directory ?? "").trim(), - context?.workspaceId?.trim() ?? "", - context?.workspaceType ?? "", - context?.targetRoot?.trim() ?? "", - context?.reason ?? "", - auth?.mode ?? (auth ? "basic" : "none"), - String(connectOptions?.quiet ?? false), - String(connectOptions?.navigate ?? true), - ].join("::"); - - const resolveConnectHealthTimeoutMs = (reason?: string) => { - const normalizedReason = reason?.trim() ?? ""; - if (LONG_BOOT_CONNECT_REASONS.has(normalizedReason)) { - return LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS; - } - return DEFAULT_CONNECT_HEALTH_TIMEOUT_MS; - }; - - const [engine, setEngine] = createSignal(null); - const [engineAuth, setEngineAuth] = createSignal(null); - const [engineDoctorResult, setEngineDoctorResult] = createSignal(null); - const [engineDoctorCheckedAt, setEngineDoctorCheckedAt] = createSignal(null); - const [engineInstallLogs, setEngineInstallLogs] = createSignal(null); - const [sandboxDoctorResult, setSandboxDoctorResult] = createSignal(null); - const [sandboxDoctorCheckedAt, setSandboxDoctorCheckedAt] = createSignal(null); - const [sandboxDoctorBusy, setSandboxDoctorBusy] = createSignal(false); - const [sandboxPreflightBusy, setSandboxPreflightBusy] = createSignal(false); - const [sandboxCreatePhase, setSandboxCreatePhase] = createSignal("idle"); - - const [sandboxCreateProgress, setSandboxCreateProgress] = createSignal(null); - const [lastSandboxCreateProgress, setLastSandboxCreateProgress] = - createSignal(null); - const clearSandboxCreateProgress = () => { - const snapshot = sandboxCreateProgress(); - if (snapshot) { - setLastSandboxCreateProgress(snapshot); - } - setSandboxCreateProgress(null); - }; - - const pushSandboxCreateLog = (line: string) => { - const value = String(line ?? "").trim(); - if (!value) return; - setSandboxCreateProgress((prev) => { - if (!prev) return prev; - const nextLogs = prev.logs.length ? prev.logs.slice(-119) : []; - // Avoid rapid duplicates. - const last = nextLogs[nextLogs.length - 1] ?? ""; - if (last !== value) nextLogs.push(value); - return { ...prev, logs: nextLogs }; - }); - }; - - const setSandboxStep = (key: SandboxCreateProgressStep["key"], patch: Partial) => { - setSandboxCreateProgress((prev) => { - if (!prev) return prev; - return { - ...prev, - steps: prev.steps.map((step) => (step.key === key ? { ...step, ...patch } : step)), - }; - }); - }; - - const setSandboxStage = (stage: string) => { - const value = String(stage ?? "").trim(); - if (!value) return; - setSandboxCreateProgress((prev) => (prev ? { ...prev, stage: value } : prev)); - }; - - const setSandboxError = (message: string) => { - const value = String(message ?? "").trim() || "Sandbox failed to start"; - setSandboxCreateProgress((prev) => (prev ? { ...prev, error: value } : prev)); - }; - - const makeRunId = () => { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; - }; - let lastEngineReconnectAt = 0; - let reconnectingEngine = false; - - const [projectDir, setProjectDir] = createSignal(""); - const [workspaces, setWorkspaces] = createSignal([]); - const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal(""); - - const syncSelectedWorkspaceId = (id: string) => { - setSelectedWorkspaceId(id); - }; - - const pickSelectedWorkspaceId = ( - nextWorkspaces: WorkspaceInfo[], - preferredIds: Array = [], - fallbackList?: { selectedId?: string; activeId?: string | null } | null, - ) => { - for (const candidate of preferredIds) { - const id = candidate?.trim() ?? ""; - if (id && nextWorkspaces.some((workspace) => workspace.id === id)) { - return id; - } - } - - const responseId = resolveWorkspaceListSelectedId(fallbackList); - if (responseId && nextWorkspaces.some((workspace) => workspace.id === responseId)) { - return responseId; - } - - return nextWorkspaces[0]?.id ?? ""; - }; - - const applyServerLocalWorkspaces = (nextLocals: WorkspaceInfo[], nextActiveId: string | null | undefined) => { - const remotes = workspaces().filter((workspace) => workspace.workspaceType === "remote"); - const merged = [...nextLocals, ...remotes]; - setWorkspaces(merged); - - syncSelectedWorkspaceId( - pickSelectedWorkspaceId(merged, [selectedWorkspaceId()], { activeId: nextActiveId ?? null }), - ); - }; - - const [authorizedDirs, setAuthorizedDirs] = createSignal([]); - const [newAuthorizedDir, setNewAuthorizedDir] = createSignal(""); - - const [workspaceConfig, setWorkspaceConfig] = createSignal(null); - const [workspaceConfigLoaded, setWorkspaceConfigLoaded] = createSignal(false); - const [createWorkspaceOpen, setCreateWorkspaceOpen] = createSignal(false); - const [createRemoteWorkspaceOpen, setCreateRemoteWorkspaceOpen] = createSignal(false); - const [editRemoteWorkspaceOpen, setEditRemoteWorkspaceOpen] = createSignal(false); - const [editRemoteWorkspaceId, setEditRemoteWorkspaceId] = createSignal(null); - const [editRemoteWorkspaceError, setEditRemoteWorkspaceError] = createSignal(null); - const [renameWorkspaceOpen, setRenameWorkspaceOpen] = createSignal(false); - const [renameWorkspaceId, setRenameWorkspaceId] = createSignal(null); - const [renameWorkspaceName, setRenameWorkspaceName] = createSignal(""); - const [renameWorkspaceBusy, setRenameWorkspaceBusy] = createSignal(false); - const [connectingWorkspaceId, setConnectingWorkspaceId] = createSignal(null); - const [connectedWorkspaceId, setConnectedWorkspaceId] = createSignal(null); - const [runtimeWorkspaceId, setRuntimeWorkspaceId] = createSignal(null); - const [runtimeWorkspaceConfigById, setRuntimeWorkspaceConfigById] = createSignal< - Record - >({}); - const [workspaceConnectionStateById, setWorkspaceConnectionStateById] = createSignal< - Record - >({}); - const [blueprintSessionMaterializeBusyByWorkspaceId, setBlueprintSessionMaterializeBusyByWorkspaceId] = - createSignal>({}); - const [blueprintSessionMaterializeAttemptedByWorkspaceId, setBlueprintSessionMaterializeAttemptedByWorkspaceId] = - createSignal>({}); - const [exportingWorkspaceConfig, setExportingWorkspaceConfig] = createSignal(false); - const [importingWorkspaceConfig, setImportingWorkspaceConfig] = createSignal(false); - const selectedWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === selectedWorkspaceId()) ?? null); - const connectedWorkspaceInfo = createMemo(() => { - const id = connectedWorkspaceId()?.trim() ?? ""; - if (!id) return null; - return workspaces().find((workspace) => workspace.id === id) ?? null; - }); - - const selectedWorkspaceDisplay = createMemo(() => { - const ws = selectedWorkspaceInfo(); - if (!ws) { - return { - id: "", - name: "Worker", - path: "", - preset: "minimal", - workspaceType: "local", - remoteType: "opencode", - baseUrl: null, - directory: null, - displayName: null, - openworkHostUrl: null, - openworkWorkspaceId: null, - openworkWorkspaceName: null, - }; - } - const displayName = - ws.displayName?.trim() || - ws.openworkWorkspaceName?.trim() || - ws.name || - ws.openworkHostUrl || - ws.baseUrl || - ws.path || - "Worker"; - return { ...ws, name: displayName }; - }); - const normalizeRemoteType = (value?: WorkspaceInfo["remoteType"] | null) => - value === "openwork" ? "openwork" : "opencode"; - const isOpenworkRemote = (workspace: WorkspaceInfo | null) => - Boolean(workspace && workspace.workspaceType === "remote" && normalizeRemoteType(workspace.remoteType) === "openwork"); - const selectedWorkspacePath = createMemo(() => { - const ws = selectedWorkspaceInfo(); - if (!ws) return ""; - if (ws.workspaceType === "remote") return ws.directory?.trim() ?? ""; - return ws.path ?? ""; - }); - const selectedWorkspaceRoot = createMemo(() => selectedWorkspacePath().trim()); - const resolveWorkspaceRuntimeRoot = (workspace: WorkspaceInfo | null | undefined) => { - if (!workspace) return ""; - if (workspace.workspaceType === "remote") { - return workspace.directory?.trim() ?? workspace.path?.trim() ?? ""; - } - return workspace.path?.trim() ?? ""; - }; - const resolveCurrentRuntimeRoot = () => { - const connectedRoot = resolveWorkspaceRuntimeRoot(connectedWorkspaceInfo()); - if (connectedRoot) return connectedRoot; - const engineRoot = engine()?.projectDir?.trim() ?? ""; - if (engineRoot) return engineRoot; - return projectDir().trim(); - }; - const runtimeWorkspaceRoot = createMemo(() => resolveCurrentRuntimeRoot().trim()); - const workspaceRootForId = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return ""; - return resolveWorkspaceRuntimeRoot(workspaces().find((workspace) => workspace.id === id) ?? null); - }; - const runtimeWorkspaceConfig = createMemo(() => { - const id = runtimeWorkspaceId()?.trim() ?? ""; - if (!id) return null; - return runtimeWorkspaceConfigById()[id] ?? null; - }); - - const editRemoteWorkspaceDefaults = createMemo(() => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return null; - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace || workspace.workspaceType !== "remote") return null; - const openworkHostUrl = - normalizeRemoteType(workspace.remoteType) === "openwork" - ? buildOpenworkWorkspaceBaseUrl( - workspace.openworkHostUrl?.trim() ?? "", - workspace.openworkWorkspaceId, - ) || - workspace.openworkHostUrl?.trim() || - workspace.baseUrl?.trim() || - "" - : workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || ""; - return { - openworkHostUrl, - openworkToken: workspace.openworkToken ?? options.openworkServer.openworkServerSettings().token ?? "", - directory: workspace.directory ?? "", - displayName: workspace.displayName ?? "", - }; - }); - - const clearSelectedSessionSurface = () => { - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - }; - - const resolveWorkspaceEntryId = (input: { - workspaceId?: string | null; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string | null; - directory?: string | null; - }) => { - const explicit = input.workspaceId?.trim() ?? ""; - if (explicit && workspaces().some((workspace) => workspace.id === explicit)) { - return explicit; - } - - const scope = normalizeDirectoryPath(input.targetRoot ?? input.directory ?? ""); - if (!scope) return null; - - const match = workspaces().find((workspace) => { - const workspaceScope = normalizeDirectoryPath( - workspace.workspaceType === "remote" - ? workspace.directory?.trim() ?? workspace.path?.trim() ?? "" - : workspace.path?.trim() ?? "", - ); - if (!workspaceScope || workspaceScope !== scope) return false; - if (input.workspaceType && workspace.workspaceType !== input.workspaceType) return false; - return true; - }); - - return match?.id ?? null; - }; - - const applySelectedWorkspacePresentation = async (workspace: WorkspaceInfo) => { - syncSelectedWorkspaceId(workspace.id); - if (workspace.workspaceType === "remote") { - setProjectDir(workspace.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - return; - } - - setProjectDir(workspace.path); - - if (isTauriRuntime()) { - setWorkspaceConfigLoaded(false); - try { - const cfg = await loadWorkspaceConfigFromOpenworkServer(workspace.path) - ?? await workspaceOpenworkRead({ workspacePath: workspace.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - if (roots.length) { - setAuthorizedDirs(roots); - } else { - setAuthorizedDirs([workspace.path]); - } - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([workspace.path]); - } - return; - } - - if (!authorizedDirs().includes(workspace.path)) { - const merged = authorizedDirs().length ? authorizedDirs().slice() : []; - if (!merged.includes(workspace.path)) merged.push(workspace.path); - setAuthorizedDirs(merged); - } - }; - - async function applyWorkspaceSelection(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((entry) => entry.id === id) ?? null; - if (!workspace) return false; - const changed = selectedWorkspaceId() !== id; - - await applySelectedWorkspacePresentation(workspace); - - if (changed) { - clearSelectedSessionSurface(); - } - - if (isTauriRuntime()) { - try { - await workspaceSetSelected(id); - } catch { - // ignore - } - } - - return true; - } - - async function selectWorkspace(workspaceId: string) { - return await applyWorkspaceSelection(workspaceId); - } - - async function switchWorkspace(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const prevProjectDir = resolveCurrentRuntimeRoot(); - return await activateWorkspace(id, { prevProjectDir }); - } - - const updateWorkspaceConnectionState = ( - workspaceId: string, - next: Partial, - ) => { - const id = workspaceId.trim(); - if (!id) return; - setWorkspaceConnectionStateById((prev) => { - const current = prev[id] ?? { status: "idle", message: null, checkedAt: null }; - return { - ...prev, - [id]: { - ...current, - ...next, - checkedAt: Date.now(), - }, - }; - }); - }; - - const clearWorkspaceConnectionState = (workspaceId: string) => { - const id = workspaceId.trim(); - if (!id) return; - setWorkspaceConnectionStateById((prev) => { - if (!prev[id]) return prev; - const next = { ...prev }; - delete next[id]; - return next; - }); - }; - - createEffect(() => { - const ids = new Set(workspaces().map((workspace) => workspace.id)); - setWorkspaceConnectionStateById((prev) => { - let changed = false; - const next: Record = {}; - for (const [id, state] of Object.entries(prev)) { - if (!ids.has(id)) { - changed = true; - continue; - } - next[id] = state; - } - return changed ? next : prev; - }); - }); - - createEffect(() => { - const client = options.openworkServer.openworkServerClient(); - const status = options.openworkServer.openworkServerStatus(); - const connectedWorkspace = connectedWorkspaceInfo(); - - if (!client || status !== "connected" || !connectedWorkspace) { - setRuntimeWorkspaceId(null); - return; - } - - const lookup = resolveRuntimeWorkspaceLookup(connectedWorkspace); - if (!lookup) { - setRuntimeWorkspaceId(null); - return; - } - - let cancelled = false; - void (async () => { - const resolved = await ensureRuntimeWorkspaceId(lookup); - if (cancelled) return; - if (!resolved) { - setRuntimeWorkspaceId(null); - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const client = options.openworkServer.openworkServerClient(); - const status = options.openworkServer.openworkServerStatus(); - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - - if (!client || status !== "connected" || !workspaceId) { - return; - } - - let cancelled = false; - void (async () => { - try { - await refreshRuntimeWorkspaceConfig(workspaceId); - } catch { - if (cancelled) return; - } - })(); - - onCleanup(() => { - cancelled = true; - }); - }); - - createEffect(() => { - const workspaceId = (runtimeWorkspaceId() ?? "").trim(); - const client = options.openworkServer.openworkServerClient(); - const connected = options.openworkServer.openworkServerStatus() === "connected"; - const root = selectedWorkspaceRoot().trim(); - const config = runtimeWorkspaceConfig() ?? workspaceConfig(); - const templates = blueprintSessions(config); - const materialized = blueprintMaterializedSessions(config); - const currentSessions = options.sessions(); - const normalizedRoot = normalizeDirectoryPath(root); - const hasWorkspaceSessions = currentSessions.some((session) => { - const directory = typeof session.directory === "string" ? session.directory : ""; - return normalizeDirectoryPath(directory) === normalizedRoot; - }); - - if (!workspaceId || !client || !connected) return; - if (!root) return; - if (!options.sessionsLoaded()) return; - if (options.creatingSession()) return; - if (options.selectedSessionId()) return; - if (!templates.length) return; - if (materialized.length > 0) return; - if (hasWorkspaceSessions) return; - if (blueprintSessionMaterializeBusyByWorkspaceId()[workspaceId]) return; - if (blueprintSessionMaterializeAttemptedByWorkspaceId()[workspaceId]) return; - - setBlueprintSessionMaterializeBusyByWorkspaceId((current) => ({ - ...current, - [workspaceId]: true, - })); - - void (async () => { - try { - const result = await client.materializeBlueprintSessions(workspaceId); - const templateMessages = new Map( - templates.map((template) => [template.id?.trim(), (template.messages ?? []).filter((entry) => entry?.text?.trim())] as const), - ); - if (result.created.length > 0) { - options.setBlueprintSeedMessagesBySessionId((current) => { - const next = { ...current }; - result.created.forEach((entry) => { - const messages = templateMessages.get(entry.templateId?.trim()); - if (messages && messages.length > 0) { - next[entry.sessionId] = messages; - } - }); - return next; - }); - } - setBlueprintSessionMaterializeAttemptedByWorkspaceId((current) => ({ - ...current, - [workspaceId]: true, - })); - await refreshRuntimeWorkspaceConfig(workspaceId); - await options.loadSessions(root || undefined); - const pending = options.pendingInitialSessionSelection?.() ?? null; - const shouldDeferInitialOpen = Boolean(pending && pending.workspaceId === workspaceId); - if (result.openSessionId && !shouldDeferInitialOpen) { - options.setView("session", result.openSessionId); - await options.selectSession(result.openSessionId, { - skipHealthCheck: true, - source: "blueprint-open-session", - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - } finally { - setBlueprintSessionMaterializeBusyByWorkspaceId((current) => { - const next = { ...current }; - delete next[workspaceId]; - return next; - }); - } - })(); - }); - - const resolveOpenworkHost = async (input: { - hostUrl: string; - token?: string | null; - workspaceId?: string | null; - directoryHint?: string | null; - }) => { - let normalizedHostUrl = normalizeOpenworkServerUrl(input.hostUrl) ?? ""; - if (!normalizedHostUrl) { - return { kind: "fallback" as const }; - } - - let inferredWorkspaceId: string | null = null; - try { - const url = new URL(normalizedHostUrl); - const segments = url.pathname.split("/").filter(Boolean); - const last = segments[segments.length - 1] ?? ""; - const prev = segments[segments.length - 2] ?? ""; - const alreadyMounted = prev === "w" && Boolean(last); - if (alreadyMounted) { - inferredWorkspaceId = decodeURIComponent(last); - const baseSegments = segments.slice(0, -2); - url.pathname = `/${baseSegments.join("/")}`; - normalizedHostUrl = url.toString().replace(/\/+$/, ""); - } - } catch { - // ignore - } - - const requestedWorkspaceId = (input.workspaceId?.trim() || inferredWorkspaceId || "").trim(); - const workspaceBaseUrl = buildOpenworkWorkspaceBaseUrl(normalizedHostUrl, requestedWorkspaceId) ?? normalizedHostUrl; - - const client = createOpenworkServerClient({ baseUrl: workspaceBaseUrl, token: input.token ?? undefined }); - - const trimmedToken = input.token?.trim() ?? ""; - - try { - const health = await client.health(); - if (!health?.ok) { - return { kind: "fallback" as const }; - } - } catch (error) { - if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) { - if (!trimmedToken) { - throw new Error("Access token required for OpenWork server."); - } - throw new Error("OpenWork server rejected the access token."); - } - return { kind: "fallback" as const }; - } - - if (!trimmedToken) { - throw new Error("Access token required for OpenWork server."); - } - - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const hint = normalizeDirectoryPath(input.directoryHint ?? ""); - const selectByHint = (entry: OpenworkWorkspaceInfo) => { - if (!hint) return false; - const entryPath = normalizeDirectoryPath( - (entry.opencode?.directory as string | undefined) ?? (entry.path as string | undefined) ?? "", - ); - return Boolean(entryPath && entryPath === hint); - }; - const selectById = (entry: OpenworkWorkspaceInfo) => Boolean(requestedWorkspaceId && entry?.id === requestedWorkspaceId); - - const workspaceById = requestedWorkspaceId - ? (items.find((item) => item?.id && selectById(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - if (requestedWorkspaceId && !workspaceById) { - throw new Error("OpenWork worker not found on that host."); - } - - const workspaceByHint = hint - ? (items.find((item) => item?.id && selectByHint(item as any)) as OpenworkWorkspaceInfo | undefined) - : undefined; - - const workspace = (workspaceById ?? workspaceByHint ?? items[0]) as OpenworkWorkspaceInfo | undefined; - if (!workspace?.id) { - throw new Error("OpenWork server did not return a worker."); - } - const opencodeUpstreamBaseUrl = workspace.opencode?.baseUrl?.trim() ?? workspace.baseUrl?.trim() ?? ""; - if (!opencodeUpstreamBaseUrl) { - throw new Error("OpenWork server did not provide an OpenCode URL."); - } - - const workspaceScopedBaseUrl = - buildOpenworkWorkspaceBaseUrl(normalizedHostUrl, workspace.id) ?? workspaceBaseUrl; - const opencodeBaseUrl = `${workspaceScopedBaseUrl.replace(/\/+$/, "")}/opencode`; - const opencodeAuth: OpencodeAuth | undefined = trimmedToken - ? { token: trimmedToken, mode: "openwork" } - : undefined; - - return { - kind: "openwork" as const, - hostUrl: normalizedHostUrl, - workspace, - opencodeBaseUrl, - directory: workspace.opencode?.directory?.trim() ?? workspace.directory?.trim() ?? "", - auth: opencodeAuth, - }; - }; - - const resolveEngineRuntime = () => options.engineRuntime?.() ?? "openwork-orchestrator"; - - const resolveWorkspacePaths = () => { - const active = selectedWorkspacePath().trim(); - const locals = workspaces() - .filter((ws) => ws.workspaceType === "local") - .map((ws) => ws.path) - .filter((path): path is string => Boolean(path && path.trim())) - .map((path) => path.trim()); - const resolved: string[] = []; - if (active) resolved.push(active); - for (const path of locals) { - if (!resolved.includes(path)) resolved.push(path); - } - return resolved; - }; - - const resolveConnectedOpenworkServer = () => { - const client = options.openworkServer.openworkServerClient(); - if (!client) return null; - if (options.openworkServer.openworkServerStatus() !== "connected") return null; - return client; - }; - - const resolveLocalOpenworkServer = async () => { - if (!isTauriRuntime()) return null; - try { - return (await options.openworkServer.ensureLocalOpenworkServerClient()) ?? null; - } catch (error) { - wsDebug("openwork:local-host:unavailable", { - message: error instanceof Error ? error.message : safeStringify(error), - }); - return null; - } - }; - - const resolveActiveOpenworkWorkspace = () => { - const client = resolveConnectedOpenworkServer(); - const workspaceId = runtimeWorkspaceId()?.trim() ?? ""; - if (!client || !workspaceId) return null; - return { client, workspaceId }; - }; - - const resolveRuntimeWorkspaceLookup = (workspace: WorkspaceInfo | null): RuntimeWorkspaceLookup | null => { - if (!workspace) return null; - if (workspace.workspaceType === "remote") { - if (normalizeRemoteType(workspace.remoteType) !== "openwork") return null; - return { - workspaceId: - workspace.openworkWorkspaceId?.trim() || - parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || - parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || - options.openworkEnvWorkspaceId?.trim() || - null, - directoryHint: workspace.directory?.trim() ?? workspace.path?.trim() ?? "", - }; - } - - return { - localRoot: workspace.path?.trim() ?? "", - }; - }; - - const resolveRuntimeWorkspaceIdFromResponse = ( - items: OpenworkWorkspaceInfo[], - activeId?: string | null, - target?: RuntimeWorkspaceLookup, - ) => { - const explicitId = target?.workspaceId?.trim() ?? ""; - if (explicitId) { - return items.find((entry) => entry?.id === explicitId)?.id ?? null; - } - - const hint = normalizeDirectoryPath(target?.directoryHint ?? target?.localRoot ?? ""); - if (hint) { - const match = items.find((entry) => { - const entryPath = normalizeDirectoryPath( - (entry.opencode?.directory as string | undefined) ?? - (entry.directory as string | undefined) ?? - (entry.path as string | undefined) ?? - "", - ); - return Boolean(entryPath && entryPath === hint); - }); - if (match?.id) return match.id; - if (target?.strictMatch) return null; - } - - const normalizedActiveId = activeId?.trim() ?? ""; - if (normalizedActiveId && items.some((entry) => entry?.id === normalizedActiveId)) { - return normalizedActiveId; - } - - return items[0]?.id ?? null; - }; - - async function ensureRuntimeWorkspaceId(target?: RuntimeWorkspaceLookup): Promise { - const explicitId = target?.workspaceId?.trim() ?? ""; - const pathHint = normalizeDirectoryPath(target?.directoryHint ?? target?.localRoot ?? ""); - const currentId = runtimeWorkspaceId()?.trim() ?? ""; - - if (!explicitId && !pathHint && currentId) { - return currentId; - } - - const client = resolveConnectedOpenworkServer(); - if (!client) { - setRuntimeWorkspaceId(null); - return null; - } - - try { - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const nextId = resolveRuntimeWorkspaceIdFromResponse(items, response.activeId, target); - setRuntimeWorkspaceId(nextId); - return nextId; - } catch (error) { - wsDebug("runtime-workspace:resolve:error", { - message: error instanceof Error ? error.message : safeStringify(error), - }); - setRuntimeWorkspaceId(null); - return null; - } - } - - const storeRuntimeWorkspaceConfig = (workspaceId: string, config: WorkspaceOpenworkConfig | null) => { - const id = workspaceId.trim(); - if (!id) return; - setRuntimeWorkspaceConfigById((current) => { - if (current[id] === config) return current; - return { - ...current, - [id]: config, - }; - }); - }; - - async function refreshRuntimeWorkspaceConfig(workspaceIdOverride?: string | null): Promise { - const client = resolveConnectedOpenworkServer(); - const workspaceId = (workspaceIdOverride ?? runtimeWorkspaceId() ?? "").trim(); - if (!client || !workspaceId) return null; - - if (options.openworkServer.openworkServerCapabilities()?.config?.read === false) { - storeRuntimeWorkspaceConfig(workspaceId, null); - return null; - } - - const workspace = connectedWorkspaceInfo() ?? selectedWorkspaceInfo(); - - try { - const config = await client.getConfig(workspaceId); - const normalized = normalizeWorkspaceOpenworkConfig( - config.openwork as WorkspaceOpenworkConfig | null | undefined, - workspace?.preset ?? "starter", - ); - const next = normalized.blueprint - ? normalized - : { - ...normalized, - blueprint: buildDefaultWorkspaceBlueprint( - normalized.workspace?.preset ?? workspace?.preset ?? "starter", - ), - }; - storeRuntimeWorkspaceConfig(workspaceId, next); - return next; - } catch (error) { - storeRuntimeWorkspaceConfig(workspaceId, null); - throw error; - } - } - - const findOpenworkWorkspaceByPathWithClient = async ( - client: OpenworkServerClient, - workspacePath: string, - ) => { - const targetPath = normalizeDirectoryPath(workspacePath); - if (!targetPath) return null; - - const response = await client.listWorkspaces(); - const items = Array.isArray(response.items) ? response.items : []; - const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath); - if (!match?.id) return null; - return { client, workspaceId: match.id, response }; - }; - - const findOpenworkWorkspaceByPath = async (workspacePath: string) => { - const client = resolveConnectedOpenworkServer(); - if (!client) return null; - return findOpenworkWorkspaceByPathWithClient(client, workspacePath); - }; - - const listWorkspaceSessions = async (workspacePath: string) => { - const client = options.client(); - if (!client) return []; - - const root = normalizeDirectoryPath(workspacePath); - const queryDirectory = resolveScopedClientDirectory({ - targetRoot: workspacePath, - workspaceType: "local", - }) || undefined; - const list = unwrap(await client.session.list({ directory: queryDirectory, roots: true })); - return root - ? list.filter((session) => normalizeDirectoryPath(session.directory) === root) - : list; - }; - - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - const ensureBackendWorkspaceReady = async ( - workspacePath: string, - name: string, - preset: WorkspacePreset, - input?: { timeoutMs?: number; pollMs?: number }, - ) => { - const timeoutMs = input?.timeoutMs ?? 15_000; - const pollMs = input?.pollMs ?? 500; - const start = Date.now(); - const localServer = await resolveLocalOpenworkServer(); - if (!localServer) { - throw new Error("Local OpenWork server is unavailable after opening the workspace."); - } - - let createAttempted = false; - while (Date.now() - start < timeoutMs) { - const resolved = await findOpenworkWorkspaceByPathWithClient(localServer, workspacePath); - if (resolved?.workspaceId) { - return resolved; - } - - if (!createAttempted) { - createAttempted = true; - await localServer.createLocalWorkspace({ folderPath: workspacePath, name, preset }); - } - - await sleep(pollMs); - } - - throw new Error("Local OpenWork server never registered the created workspace."); - }; - - const materializeStarterSessions = async ( - workspacePath: string, - name: string, - preset: WorkspacePreset, - ) => { - if (preset !== "starter") return null; - const localWorkspace = await ensureBackendWorkspaceReady(workspacePath, name, preset); - return await localWorkspace.client.materializeBlueprintSessions(localWorkspace.workspaceId); - }; - - const waitForWorkspaceSessionsReady = async ( - workspacePath: string, - input?: { timeoutMs?: number; pollMs?: number }, - ) => { - const timeoutMs = input?.timeoutMs ?? 30_000; - const pollMs = input?.pollMs ?? 500; - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - try { - const sessions = await listWorkspaceSessions(workspacePath); - if (sessions.length > 0) { - await options.loadSessions(workspacePath); - return true; - } - } catch { - // keep polling while the local engine/session index settles - } - - await sleep(pollMs); - } - - try { - await options.loadSessions(workspacePath); - return (await listWorkspaceSessions(workspacePath)).length > 0; - } catch { - return false; - } - }; - - const loadWorkspaceConfigFromOpenworkServer = async (workspacePath: string): Promise => { - const resolved = await findOpenworkWorkspaceByPath(workspacePath); - if (!resolved) return null; - const config = await resolved.client.getConfig(resolved.workspaceId); - return (config.openwork as WorkspaceOpenworkConfig | null | undefined) ?? null; - }; - - const persistWorkspaceConfigToOpenworkServer = async (config: WorkspaceOpenworkConfig): Promise => { - const active = resolveActiveOpenworkWorkspace(); - if (!active) return false; - await active.client.patchConfig(active.workspaceId, { openwork: config as Record }); - return true; - }; - - const activateOpenworkHostWorkspace = async (workspacePath: string) => { - const resolved = await findOpenworkWorkspaceByPath(workspacePath); - if (!resolved) return; - try { - if (resolved.response.activeId === resolved.workspaceId) return; - await resolved.client.activateWorkspace(resolved.workspaceId); - } catch { - // ignore - } - }; - - async function testWorkspaceConnection(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - if (workspace.workspaceType !== "remote") { - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } - - const remoteType = normalizeRemoteType(workspace.remoteType); - - if (remoteType === "openwork") { - const hostUrl = - workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || workspace.path?.trim() || ""; - if (!hostUrl) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server URL is required.", - }); - return false; - } - - const token = workspace.openworkToken?.trim() || options.openworkServer.openworkServerSettings().token || undefined; - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: workspace.openworkWorkspaceId ?? null, - }); - if (resolved.kind !== "openwork") { - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server unavailable. Check the URL and token.", - }); - return false; - } - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - } - - const baseUrl = workspace.baseUrl?.trim() || ""; - if (!baseUrl) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - try { - const client = createClient(baseUrl, workspace.directory?.trim() || undefined); - await waitForHealthy(client, { timeoutMs: 8_000 }); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - } - - async function refreshEngine() { - if (!isTauriRuntime()) return; - - try { - const info = await engineInfo(); - setEngine(info); - - const connectedWorkspace = connectedWorkspaceInfo(); - const syncLocalState = connectedWorkspace?.workspaceType !== "remote"; - - const username = info.opencodeUsername?.trim() ?? ""; - const password = info.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : null; - setEngineAuth(auth); - - if (info.projectDir && syncLocalState) { - setProjectDir(info.projectDir); - } - if (info.baseUrl && syncLocalState) { - options.setBaseUrl(info.baseUrl); - } - - if ( - syncLocalState && - info.running && - info.baseUrl && - !options.client() && - !reconnectingEngine - ) { - const now = Date.now(); - if (now - lastEngineReconnectAt > 10_000) { - const reconnectRoot = - (connectedWorkspace?.workspaceType === "local" - ? connectedWorkspace.path?.trim() - : connectedWorkspace?.directory?.trim()) || - info.projectDir?.trim() || - ""; - lastEngineReconnectAt = now; - reconnectingEngine = true; - connectToServer( - info.baseUrl, - reconnectRoot || undefined, - { workspaceType: "local", targetRoot: reconnectRoot, reason: "engine-refresh" }, - auth ?? undefined, - { quiet: true, navigate: false }, - ) - .catch(() => undefined) - .finally(() => { - reconnectingEngine = false; - }); - } - } - } catch { - // ignore - } - } - - async function refreshEngineDoctor() { - if (!isTauriRuntime()) return; - - try { - const source = options.engineSource(); - const result = await engineDoctor({ - preferSidecar: source === "sidecar", - opencodeBinPath: source === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - }); - setEngineDoctorResult(result); - setEngineDoctorCheckedAt(Date.now()); - } catch (e) { - setEngineDoctorResult(null); - setEngineDoctorCheckedAt(Date.now()); - setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); - } - } - - async function refreshSandboxDoctor() { - if (!isTauriRuntime()) { - setSandboxDoctorResult(null); - setSandboxDoctorCheckedAt(Date.now()); - return null; - } - if (sandboxDoctorBusy()) return sandboxDoctorResult(); - setSandboxDoctorBusy(true); - try { - const result = await sandboxDoctor(); - setSandboxDoctorResult(result); - return result; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - const fallback: SandboxDoctorResult = { - installed: false, - daemonRunning: false, - permissionOk: false, - ready: false, - error: message, - }; - setSandboxDoctorResult(fallback); - return fallback; - } finally { - setSandboxDoctorCheckedAt(Date.now()); - setSandboxDoctorBusy(false); - } - } - - async function activateWorkspace( - workspaceId: string, - hint?: { prevProjectDir?: string }, - ) { - const id = workspaceId.trim(); - if (!id) return false; - - const capturedPrevDir = hint?.prevProjectDir?.trim() || resolveCurrentRuntimeRoot(); - - const next = workspaces().find((w) => w.id === id) ?? null; - if (!next) return false; - if (connectedWorkspaceId() === id && options.client()) { - if (selectedWorkspaceId() !== id) { - await applyWorkspaceSelection(id); - } - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:noop-already-connected", { id }); - return true; - } - if (selectedWorkspaceId() !== id) { - await applyWorkspaceSelection(id); - } - const isRemote = next.workspaceType === "remote"; - console.log("[workspace] activate", { id: next.id, type: next.workspaceType }); - const activateStart = Date.now(); - wsDebug("activate:start", { - id: next.id, - type: next.workspaceType, - remoteType: next.remoteType ?? null, - prevActiveId: selectedWorkspaceId(), - prevProjectDir: capturedPrevDir, - currentProjectDir: projectDir(), - startupPref: options.startupPreference(), - hasClient: Boolean(options.client()), - }); - - const remoteType = isRemote ? normalizeRemoteType(next.remoteType) : "opencode"; - const baseUrl = isRemote ? next.baseUrl?.trim() ?? "" : ""; - - setConnectingWorkspaceId(id); - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - // Allow the UI to paint the "switching" state before we kick off work that can - // trigger expensive reactive updates (e.g. sidebar session refreshes). - if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { - await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); - } - - try { - if (isRemote) { - options.setStartupPreference("server"); - - if (remoteType === "openwork") { - const hostUrl = next.openworkHostUrl?.trim() ?? ""; - if (!hostUrl) { - options.setError("OpenWork server URL is required."); - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server URL is required.", - }); - return false; - } - - const workspaceToken = next.openworkToken?.trim() ?? ""; - const fallbackToken = options.openworkServer.openworkServerSettings().token ?? ""; - const token = workspaceToken || fallbackToken; - - const currentSettings = options.openworkServer.openworkServerSettings(); - if ( - currentSettings.urlOverride?.trim() !== hostUrl || - (token && currentSettings.token?.trim() !== token) - ) { - options.openworkServer.updateOpenworkServerSettings({ - ...currentSettings, - urlOverride: hostUrl, - token: token || currentSettings.token, - }); - } - - let resolvedBaseUrl = baseUrl; - let resolvedDirectory = next.directory?.trim() ?? ""; - let workspaceInfo: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = token ? { token, mode: "openwork" } : undefined; - - const finishRemoteWorkspaceActivation = async (shouldPersistResolved: boolean) => { - if (shouldPersistResolved) { - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateRemote({ - workspaceId: next.id, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: resolvedDirectory || null, - openworkHostUrl: hostUrl, - openworkToken: token ? token : null, - openworkWorkspaceId: workspaceInfo?.id ?? next.openworkWorkspaceId ?? null, - openworkWorkspaceName: workspaceInfo?.name ?? next.openworkWorkspaceName ?? null, - }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - } catch { - // ignore - } - } else { - const resolvedToken = token.trim(); - setWorkspaces((prev) => - prev.map((ws) => { - if (ws.id !== next.id) return ws; - return { - ...ws, - remoteType: "openwork", - baseUrl: resolvedBaseUrl.replace(/\/+$/, ""), - directory: resolvedDirectory || null, - openworkHostUrl: hostUrl, - openworkToken: resolvedToken || null, - openworkWorkspaceId: workspaceInfo?.id ?? ws.openworkWorkspaceId ?? null, - openworkWorkspaceName: workspaceInfo?.name ?? ws.openworkWorkspaceName ?? null, - }; - }), - ); - } - } - - syncSelectedWorkspaceId(id); - setProjectDir(resolvedDirectory || ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - }; - - if (resolvedBaseUrl) { - const cachedOk = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: resolvedDirectory ?? "", - reason: "workspace-switch-openwork-cached", - }, - resolvedAuth, - { navigate: false, quiet: true }, - ); - - if (cachedOk) { - wsDebug("activate:remote:cached", { id, hostUrl, resolvedBaseUrl, resolvedDirectory }); - return await finishRemoteWorkspaceActivation(false); - } - } - - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: next.openworkWorkspaceId ?? null, - directoryHint: next.directory ?? null, - }); - if (resolved.kind !== "openwork") { - options.setError("OpenWork server unavailable. Check the URL and token."); - updateWorkspaceConnectionState(id, { - status: "error", - message: "OpenWork server unavailable. Check the URL and token.", - }); - return false; - } - - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory; - workspaceInfo = resolved.workspace; - resolvedAuth = resolved.auth; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: resolvedDirectory ?? "", - reason: "workspace-switch-openwork", - }, - resolvedAuth, - { navigate: false }, - ); - - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - return await finishRemoteWorkspaceActivation(true); - } - - if (!baseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: "Remote base URL is required.", - }); - return false; - } - - const ok = await connectToServer( - baseUrl, - next.directory?.trim() || undefined, - { - workspaceId: next.id, - workspaceType: next.workspaceType, - targetRoot: next.directory?.trim() ?? "", - reason: "workspace-switch-direct", - }, - undefined, - { navigate: false }, - ); - - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - - syncSelectedWorkspaceId(id); - setProjectDir(next.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:remote:done", { id, ms: Date.now() - activateStart }); - return true; - } - - const wasLocalConnection = options.startupPreference() === "local" && options.client(); - options.setStartupPreference("local"); - const nextRoot = isRemote ? next.directory?.trim() ?? "" : next.path; - // Use the pre-switch snapshot instead of projectDir() which may already - // point at the new workspace due to applySelectedWorkspacePresentation. - const oldWorkspacePath = capturedPrevDir; - const workspaceChanged = oldWorkspacePath !== nextRoot; - - wsDebug("activate:local:prep", { - id, - nextRoot, - workspaceChanged, - wasLocalConnection: Boolean(wasLocalConnection), - prevProjectDir: oldWorkspacePath, - }); - - syncSelectedWorkspaceId(id); - setProjectDir(nextRoot); - - if (isTauriRuntime()) { - if (isRemote) { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - } else { - setWorkspaceConfigLoaded(false); - try { - const cfg = await loadWorkspaceConfigFromOpenworkServer(next.path) - ?? await workspaceOpenworkRead({ workspacePath: next.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - if (roots.length) { - setAuthorizedDirs(roots); - } else { - setAuthorizedDirs([next.path]); - } - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([next.path]); - } - } - - try { - if (!isRemote) { - await activateOpenworkHostWorkspace(next.path); - } - await workspaceSetRuntimeActive(id); - } catch { - // ignore - } - } else if (!isRemote) { - if (!authorizedDirs().includes(next.path)) { - const merged = authorizedDirs().length ? authorizedDirs().slice() : []; - if (!merged.includes(next.path)) merged.push(next.path); - setAuthorizedDirs(merged); - } - } else { - setAuthorizedDirs([]); - } - - // If we were previously connected to a remote engine, switching back to a local workspace - // requires starting (or reconnecting) the local host engine. - // - // Without this, we end up keeping the remote client while `startupPreference` flips to - // "local", and subsequent session/file actions behave inconsistently. - if (!isRemote && options.client() && !wasLocalConnection) { - wsDebug("activate:remote->local:reconnect", { - id, - nextPath: next.path, - engine: engine()?.baseUrl ?? null, - engineRunning: Boolean(engine()?.running), - }); - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - - // If a local host engine is already running (common when bouncing between remote/local), - // reuse it instead of restarting to keep switching snappy. - let connectedToLocalHost = false; - const existingEngine = engine(); - const runtime = existingEngine?.runtime ?? resolveEngineRuntime(); - const canReuseHost = - isTauriRuntime() && - Boolean(existingEngine?.running && existingEngine.baseUrl); - - wsDebug("activate:remote->local:hostReuse", { - canReuseHost, - runtime, - existingEngineBaseUrl: existingEngine?.baseUrl ?? null, - existingEngineProjectDir: existingEngine?.projectDir ?? null, - }); - - if (canReuseHost && runtime === "openwork-orchestrator") { - try { - const reuseStart = Date.now(); - await orchestratorWorkspaceActivate({ - workspacePath: next.path, - name: next.displayName?.trim() || next.name?.trim() || null, - }); - await activateOpenworkHostWorkspace(next.path); - - const nextInfo = await engineInfo(); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - connectedToLocalHost = await connectToServer( - nextInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-attach-local" }, - auth, - { navigate: false }, - ); - } - wsDebug("activate:remote->local:reuseHost:done", { - ok: connectedToLocalHost, - ms: Date.now() - reuseStart, - }); - } catch { - connectedToLocalHost = false; - wsDebug("activate:remote->local:reuseHost:error"); - } - } - - if (!connectedToLocalHost) { - const startHostAt = Date.now(); - const ok = await startHost({ workspacePath: next.path, navigate: false }); - wsDebug("activate:remote->local:startHost:done", { ok, ms: Date.now() - startHostAt }); - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to start local engine.", - }); - return false; - } - } - } - - // When running locally, restart the engine when workspace changes - if (!isRemote && wasLocalConnection && workspaceChanged) { - wsDebug("activate:local->local:restartEngine", { id, nextPath: next.path }); - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.restarting_engine"); - options.setBusyStartedAt(Date.now()); - - try { - const runtime = resolveEngineRuntime(); - if (runtime === "openwork-orchestrator") { - await orchestratorWorkspaceActivate({ - workspacePath: next.path, - name: next.displayName?.trim() || next.name?.trim() || null, - }); - await activateOpenworkHostWorkspace(next.path); - - const newInfo = await engineInfo(); - setEngine(newInfo); - - const username = newInfo.opencodeUsername?.trim() ?? ""; - const password = newInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (newInfo.baseUrl) { - const ok = await connectToServer( - newInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-orchestrator-switch" }, - auth, - { navigate: false }, - ); - if (!ok) { - options.setError("Failed to reconnect after worker switch"); - } - } - } else { - // Stop the current engine - const info = await engineStop(); - setEngine(info); - - // Start engine with new workspace directory - const newInfo = await engineStart(next.path, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime, - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(newInfo); - - const username = newInfo.opencodeUsername?.trim() ?? ""; - const password = newInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - // Reconnect to server - if (newInfo.baseUrl) { - const ok = await connectToServer( - newInfo.baseUrl, - next.path, - { workspaceType: "local", targetRoot: next.path, reason: "workspace-restart" }, - auth, - { navigate: false }, - ); - if (!ok) { - options.setError("Failed to reconnect after worker switch"); - } - } - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - wsDebug("activate:local:done", { id, ms: Date.now() - activateStart }); - return true; - } finally { - setConnectingWorkspaceId(null); - wsDebug("activate:finally", { id, ms: Date.now() - activateStart }); - } - } - - async function connectToServer( - nextBaseUrl: string, - directory?: string, - context?: { - workspaceId?: string; - workspaceType?: WorkspaceInfo["workspaceType"]; - targetRoot?: string; - reason?: string; - }, - auth?: OpencodeAuth, - connectOptions?: { quiet?: boolean; navigate?: boolean }, - ) { - const requestKey = connectRequestKey(nextBaseUrl, directory, context, auth, connectOptions); - const existing = connectInFlightByKey.get(requestKey); - if (existing) { - wsDebug("connect:dedupe", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - reason: context?.reason ?? null, - workspaceType: context?.workspaceType ?? null, - }); - return existing; - } - - const run = (async () => { - console.log("[workspace] connect", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - workspaceType: context?.workspaceType ?? null, - }); - const connectStart = Date.now(); - wsDebug("connect:start", { - baseUrl: nextBaseUrl, - directory: directory ?? null, - directoryScope: describeDirectoryScope(directory), - reason: context?.reason ?? null, - workspaceType: context?.workspaceType ?? null, - targetRoot: context?.targetRoot ?? null, - targetRootScope: describeDirectoryScope(context?.targetRoot), - workspaceId: context?.workspaceId ?? null, - selectedWorkspaceId: selectedWorkspaceId() || null, - selectedWorkspaceRoot: selectedWorkspaceRoot().trim() || null, - activeWorkspaceScope: describeDirectoryScope(selectedWorkspaceRoot().trim()), - projectDir: projectDir().trim() || null, - clientDirectory: options.clientDirectory().trim() || null, - healthTimeoutMs: resolveConnectHealthTimeoutMs(context?.reason), - quiet: connectOptions?.quiet ?? false, - navigate: connectOptions?.navigate ?? true, - authMode: auth && "mode" in auth ? (auth as any).mode : auth ? "basic" : "none", - }); - const quiet = connectOptions?.quiet ?? false; - const navigate = connectOptions?.navigate ?? true; - options.setError(null); - if (!quiet) { - options.setBusy(true); - options.setBusyLabel("status.connecting"); - options.setBusyStartedAt(Date.now()); - } - options.setSseConnected(false); - - const connectMeta: OpencodeConnectStatus = { - at: Date.now(), - baseUrl: nextBaseUrl, - directory: directory ?? null, - reason: context?.reason ?? null, - status: "connecting", - error: null, - }; - options.setOpencodeConnectStatus?.(connectMeta); - - const connectMetrics: NonNullable = {}; - - try { - let resolvedDirectory = resolveScopedClientDirectory({ - directory, - targetRoot: context?.targetRoot, - workspaceType: context?.workspaceType ?? "local", - }); - let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth); - const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason); - const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs }); - connectMetrics.healthyMs = Date.now() - connectStart; - wsDebug("connect:healthy", { - ms: Date.now() - connectStart, - version: health.version, - timeoutMs: healthTimeoutMs, - resolvedDirectory: resolvedDirectory || null, - resolvedDirectoryScope: describeDirectoryScope(resolvedDirectory), - }); - - if (context?.workspaceType === "remote" && !resolvedDirectory) { - try { - const pathInfo = unwrap(await nextClient.path.get()); - const discovered = toSessionTransportDirectory(pathInfo.directory); - if (discovered) { - resolvedDirectory = discovered; - console.log("[workspace] remote directory resolved", resolvedDirectory); - if (isTauriRuntime() && context.workspaceId) { - const updated = await workspaceUpdateRemote({ - workspaceId: context.workspaceId, - directory: resolvedDirectory, - }); - setWorkspaces(updated.workspaces); - syncSelectedWorkspaceId( - pickSelectedWorkspaceId(updated.workspaces, [context.workspaceId, selectedWorkspaceId()], updated), - ); - } - setProjectDir(resolvedDirectory); - nextClient = createClient(nextBaseUrl, resolvedDirectory, auth); - } - } catch (error) { - console.log("[workspace] remote directory lookup failed", error); - } - } - - options.setClient(nextClient); - options.setConnectedVersion(health.version); - options.setBaseUrl(nextBaseUrl); - options.setClientDirectory(resolvedDirectory); - setConnectedWorkspaceId( - resolveWorkspaceEntryId({ - workspaceId: context?.workspaceId ?? null, - workspaceType: context?.workspaceType, - targetRoot: context?.targetRoot ?? resolvedDirectory, - directory: resolvedDirectory, - }), - ); - - const providersPromise = (async () => { - const providersAt = Date.now(); - wsDebug("connect:providers:start", { baseUrl: nextBaseUrl }); - let disabledProviders: string[] = []; - try { - const config = unwrap(await nextClient.config.get()); - disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; - } catch { - // ignore config read failures and continue with provider discovery - } - try { - const providerList = unwrap(await nextClient.provider.list()); - wsDebug("connect:providers:done", { - ms: Date.now() - providersAt, - source: "provider.list", - available: providerList.all?.length ?? 0, - connected: providerList.connected?.length ?? 0, - }); - const next = filterProviderList(providerList, disabledProviders); - return { - providers: next.all, - defaults: next.default, - connectedIds: next.connected, - }; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - wsDebug("connect:providers:fallback", { ms: Date.now() - providersAt, message }); - try { - const cfg = unwrap(await nextClient.config.providers()); - const mapped = mapConfigProvidersToList(cfg.providers); - wsDebug("connect:providers:done", { - ms: Date.now() - providersAt, - source: "config.providers", - available: mapped.length, - connected: 0, - }); - const next = filterProviderList( - { all: mapped, connected: [], default: cfg.default }, - disabledProviders, - ); - return { - providers: next.all, - defaults: next.default, - connectedIds: next.connected, - }; - } catch (fallbackError) { - const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : safeStringify(fallbackError); - wsDebug("connect:providers:error", { ms: Date.now() - providersAt, message: fallbackMessage }); - return { - providers: [], - defaults: {}, - connectedIds: [], - }; - } - } finally { - connectMetrics.providersMs = Date.now() - providersAt; - } - })(); - - const targetRoot = context?.targetRoot ?? (resolvedDirectory || selectedWorkspaceRoot().trim()); - wsDebug("connect:loadSessions", { - targetRoot, - targetRootScope: describeDirectoryScope(targetRoot), - resolvedDirectory, - resolvedDirectoryScope: describeDirectoryScope(resolvedDirectory), - selectedWorkspaceId: selectedWorkspaceId() || null, - selectedWorkspaceRoot: selectedWorkspaceRoot().trim() || null, - }); - const sessionsAt = Date.now(); - await options.loadSessions(targetRoot); - connectMetrics.loadSessionsMs = Date.now() - sessionsAt; - wsDebug("connect:loadSessions:done", { ms: Date.now() - sessionsAt }); - options.onBootPhaseChange?.("sessionIndexReady", { - source: context?.reason ?? "connectToServer", - targetRoot: targetRoot || null, - }); - options.onStartupTrace?.("session-index-ready", { - source: context?.reason ?? "connectToServer", - targetRoot: targetRoot || null, - }); - const pendingPermissionsAt = Date.now(); - await options.refreshPendingPermissions(); - connectMetrics.pendingPermissionsMs = Date.now() - pendingPermissionsAt; - - const providerState = await providersPromise; - options.setProviders(providerState.providers); - options.setProviderDefaults(providerState.defaults); - options.setProviderConnectedIds(providerState.connectedIds); - - if (navigate && !options.selectedSessionId()) { - options.setSettingsTab("automations"); - options.setView("session"); - } - - options.onEngineStable?.(); - connectMetrics.totalMs = Date.now() - connectStart; - options.setOpencodeConnectStatus?.({ ...connectMeta, status: "connected", metrics: connectMetrics }); - wsDebug("connect:done", { ok: true, ms: Date.now() - connectStart }); - return true; - } catch (e) { - options.setClient(null); - options.setConnectedVersion(null); - setConnectedWorkspaceId(null); - const message = e instanceof Error ? e.message : safeStringify(e); - wsDebug("connect:error", { ms: Date.now() - connectStart, message }); - connectMetrics.totalMs = Date.now() - connectStart; - options.setOpencodeConnectStatus?.({ - ...connectMeta, - status: "error", - error: addOpencodeCacheHint(message), - metrics: connectMetrics, - }); - if (!quiet) { - options.setError(addOpencodeCacheHint(message)); - } - return false; - } finally { - if (!quiet) { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - })(); - - connectInFlightByKey.set(requestKey, run); - try { - return await run; - } finally { - if (connectInFlightByKey.get(requestKey) === run) { - connectInFlightByKey.delete(requestKey); - } - } - } - - const openEmptySession = async (scopeRoot?: string) => { - const root = (scopeRoot ?? selectedWorkspaceRoot().trim()).trim(); - wsDebug("open-empty-session:start", { - scopeRoot: scopeRoot ?? null, - resolvedRoot: root || null, - selectedWorkspaceId: selectedWorkspaceId(), - activeWorkspace: selectedWorkspaceInfo(), - hasClient: Boolean(options.client()), - }); - - if (options.client()) { - try { - await options.loadSessions(root || undefined); - } catch { - // If session loading fails, still fall back to the draft-ready session view. - } - } - - clearSelectedSessionSurface(); - options.setView("session"); - }; - - const activateFreshLocalWorkspace = async (workspaceId: string | null, workspacePath: string) => { - const hasClient = Boolean(options.client()); - const ok = hasClient - ? workspaceId - ? await activateWorkspace(workspaceId) - : true - : await startHost({ workspacePath, navigate: false }); - - if (!ok) { - return false; - } - return true; - }; - - async function createWorkspaceFlow(preset: WorkspacePreset, folder: string | null): Promise { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - if (!folder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - options.setBusy(true); - options.setBusyLabel("status.creating_workspace"); - options.setBusyStartedAt(Date.now()); - options.setError(null); - clearSandboxCreateProgress(); - setSandboxPreflightBusy(false); - - try { - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - const name = deriveWorkspaceName(resolvedFolder, preset); - const openworkServer = await resolveLocalOpenworkServer(); - const ws = openworkServer - ? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset }) - : await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - - const createdWorkspaceId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - - if (openworkServer && isTauriRuntime()) { - try { - await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - } catch { - // keep the server result as the source of truth for this run - } - } - - const nextSelectedId = createdWorkspaceId; - applyServerLocalWorkspaces(ws.workspaces, nextSelectedId); - if (nextSelectedId) { - const nextSelectedWorkspace = ws.workspaces.find((workspace) => workspace.id === nextSelectedId) ?? null; - if (nextSelectedWorkspace) { - await applySelectedWorkspacePresentation(nextSelectedWorkspace); - } else { - syncSelectedWorkspaceId(nextSelectedId); - } - updateWorkspaceConnectionState(nextSelectedId, { status: "connected", message: null }); - } - - queuePendingInitialSessionSelection(nextSelectedId || null, preset); - - setCreateWorkspaceOpen(false); - - const opened = await activateFreshLocalWorkspace(nextSelectedId || null, resolvedFolder); - if (!opened) { - options.setPendingInitialSessionSelection?.(null); - return false; - } - - if (preset === "starter") { - const materialized = await materializeStarterSessions(resolvedFolder, name, preset); - const sessionsReady = await waitForWorkspaceSessionsReady(resolvedFolder); - if (!sessionsReady) { - throw new Error("Starter sessions did not finish loading for the new workspace."); - } - if (nextSelectedId) { - await options.refreshWorkspaceSessions?.(nextSelectedId); - } - const openSessionId = materialized?.openSessionId?.trim() || ""; - if (openSessionId) { - options.setPendingInitialSessionSelection?.(null); - options.setSelectedSessionId(openSessionId); - options.setView("session", openSessionId); - await options.selectSession(openSessionId, { - skipHealthCheck: true, - source: "create-workspace-open-session", - }); - } - } - - if (!nextSelectedId) { - await openEmptySession(resolvedFolder); - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function createSandboxFlow( - preset: WorkspacePreset, - folder: string | null, - input?: { onReady?: () => Promise | void }, - ): Promise { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - if (!folder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return false; - } - - const runId = makeRunId(); - const startedAt = Date.now(); - const sandboxMode = resolveSandboxCreateMode(options.useMicrosandboxCreateSandbox?.() === true); - setSandboxCreatePhase("preflight"); - setSandboxPreflightBusy(true); - options.setError(null); - clearSandboxCreateProgress(); - - const doctor = await refreshSandboxDoctor(); - setSandboxPreflightBusy(false); - setSandboxCreatePhase("provisioning"); - setSandboxCreateProgress({ - runId, - startedAt, - stage: sandboxMode.runtimeCheckingStage, - error: null, - logs: [], - steps: [ - { key: "docker", label: sandboxMode.runtimeReadyLabel, status: "active", detail: null }, - { key: "workspace", label: "Prepare worker", status: "pending", detail: null }, - { key: "sandbox", label: "Start sandbox services", status: "pending", detail: null }, - { key: "health", label: "Wait for OpenWork", status: "pending", detail: null }, - { key: "connect", label: "Connect in OpenWork", status: "pending", detail: null }, - ], - }); - - if (doctor?.debug) { - const selectedBin = doctor.debug.selectedBin?.trim(); - if (selectedBin) { - pushSandboxCreateLog(`Docker binary: ${selectedBin}`); - } - const candidates = (doctor.debug.candidates ?? []).filter((item) => item?.trim()); - if (candidates.length) { - pushSandboxCreateLog(`Docker candidates: ${candidates.join(", ")}`); - } - const versionDebug = doctor.debug.versionCommand; - if (versionDebug) { - pushSandboxCreateLog(`docker --version exit=${versionDebug.status}`); - if (versionDebug.stderr?.trim()) pushSandboxCreateLog(`docker --version stderr: ${versionDebug.stderr.trim()}`); - } - const infoDebug = doctor.debug.infoCommand; - if (infoDebug) { - pushSandboxCreateLog(`docker info exit=${infoDebug.status}`); - if (infoDebug.stderr?.trim()) pushSandboxCreateLog(`docker info stderr: ${infoDebug.stderr.trim()}`); - } - } - if (!doctor?.ready) { - const detail = - doctor?.error?.trim() || - "Docker is required for sandboxes. Install Docker Desktop, start it, then retry."; - options.setError(detail); - setSandboxStep("docker", { status: "error", detail }); - setSandboxError(detail); - setSandboxStage("Docker not ready"); - setSandboxCreatePhase("idle"); - return false; - } - setSandboxStep("docker", { status: "done", detail: doctor.serverVersion ?? null }); - setSandboxStage("Preparing worker..."); - - try { - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - setSandboxStep("workspace", { status: "error", detail: "No folder selected" }); - setSandboxError("No folder selected"); - return false; - } - - const name = deriveWorkspaceName(resolvedFolder, preset); - - setSandboxStep("workspace", { status: "active", detail: name }); - pushSandboxCreateLog(`Worker: ${resolvedFolder}`); - - // Ensure the workspace folder has baseline OpenWork/OpenCode files. - const openworkServer = await resolveLocalOpenworkServer(); - const created = openworkServer - ? await openworkServer.createLocalWorkspace({ folderPath: resolvedFolder, name, preset }) - : await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - if (openworkServer && isTauriRuntime()) { - try { - await workspaceCreate({ folderPath: resolvedFolder, name, preset }); - } catch { - // ignore desktop mirror failures here - } - } - const localId = pickSelectedWorkspaceId(created.workspaces, [resolveWorkspaceListSelectedId(created)], created); - applyServerLocalWorkspaces(created.workspaces, localId); - if (localId) { - syncSelectedWorkspaceId(localId); - } - setSandboxStep("workspace", { status: "done", detail: null }); - - // Remove the local workspace entry to avoid duplicate Local+Remote rows. - if (localId) { - pushSandboxCreateLog("Removing local worker row (will re-add as remote sandbox)..."); - const activeLocalWorkspace = openworkServer ? await findOpenworkWorkspaceByPath(resolvedFolder) : null; - const forgotten = await (activeLocalWorkspace - ? (() => activeLocalWorkspace.client.deleteWorkspace(activeLocalWorkspace.workspaceId).then((response) => ({ - activeId: response.activeId ?? "", - workspaces: response.workspaces ?? response.items, - })))() - : workspaceForget(localId)); - if (activeLocalWorkspace && isTauriRuntime()) { - try { - await workspaceForget(localId); - } catch { - // ignore desktop mirror failures here - } - } - applyServerLocalWorkspaces(forgotten.workspaces, forgotten.activeId); - } - - setSandboxStep("sandbox", { status: "active", detail: null }); - setSandboxStage("Starting sandbox services..."); - - let stopListen: (() => void) | null = null; - try { - stopListen = await listen( - "openwork://sandbox-create-progress", - (event: TauriEvent<{ runId?: string; stage?: string; message?: string; payload?: any }>) => { - const payload = event.payload ?? {}; - if ((payload.runId ?? "").trim() !== runId) return; - const stage = String(payload.stage ?? "").trim(); - const message = String(payload.message ?? "").trim(); - if (message) { - setSandboxStage(message); - pushSandboxCreateLog(message); - } - - if (stage === "docker.container") { - const state = String(payload.payload?.containerState ?? "").trim(); - if (state) { - setSandboxStep("sandbox", { status: "active", detail: `Container: ${state}` }); - } - } - - if (stage === "docker.config") { - const selected = String(payload.payload?.openworkDockerBin ?? "").trim(); - if (selected) { - pushSandboxCreateLog(`OPENWORK_DOCKER_BIN=${selected}`); - } - const resolved = String(payload.payload?.resolvedDockerBin ?? "").trim(); - if (resolved) { - pushSandboxCreateLog(`Resolved docker: ${resolved}`); - } - const candidates = Array.isArray(payload.payload?.candidates) - ? payload.payload.candidates.filter((item: unknown) => String(item ?? "").trim()) - : []; - if (candidates.length) { - pushSandboxCreateLog(`Docker probe paths: ${candidates.join(", ")}`); - } - } - - if (stage === "docker.inspect") { - const inspectError = String(payload.payload?.error ?? "").trim(); - if (inspectError) { - setSandboxStep("sandbox", { status: "active", detail: "Docker inspect warning" }); - pushSandboxCreateLog(`docker inspect warning: ${inspectError}`); - } - } - - if (stage === "openwork.waiting") { - const elapsedMs = Number(payload.payload?.elapsedMs ?? 0); - const seconds = elapsedMs > 0 ? Math.max(1, Math.floor(elapsedMs / 1000)) : 0; - setSandboxStep("health", { status: "active", detail: seconds ? `${seconds}s` : null }); - const probeError = String(payload.payload?.containerProbeError ?? "").trim(); - if (probeError) { - pushSandboxCreateLog(`Container probe: ${probeError}`); - } - } - - if (stage === "openwork.healthy") { - setSandboxStep("sandbox", { status: "done" }); - setSandboxStep("health", { status: "done", detail: null }); - } - - if (stage === "error") { - const err = String(payload.payload?.error ?? "").trim() || message || "Sandbox failed to start"; - setSandboxStep("sandbox", { status: "error", detail: err }); - setSandboxStep("health", { status: "error", detail: err }); - setSandboxError(err); - } - }, - ); - - const host = await orchestratorStartDetached({ - workspacePath: resolvedFolder, - sandboxBackend: sandboxMode.backend, - sandboxImageRef: sandboxMode.sandboxImageRef, - runId, - }); - setSandboxStep("sandbox", { status: "done", detail: host.sandboxContainerName ?? null }); - setSandboxStep("health", { status: "done" }); - setSandboxStage("Connecting to sandbox..."); - - setSandboxStep("connect", { status: "active", detail: null }); - - const ok = await createRemoteWorkspaceFlow({ - openworkHostUrl: host.openworkUrl, - openworkToken: host.ownerToken?.trim() || host.token, - openworkClientToken: host.token, - openworkHostToken: host.hostToken, - directory: resolvedFolder, - displayName: name, - sandboxBackend: host.sandboxBackend ?? sandboxMode.backend, - sandboxRunId: host.sandboxRunId ?? runId, - sandboxContainerName: host.sandboxContainerName ?? null, - manageBusy: false, - closeModal: false, - }); - if (!ok) { - const fallback = "Failed to connect to sandbox"; - pushSandboxCreateLog(fallback); - setSandboxStep("connect", { status: "error", detail: fallback }); - setSandboxError(fallback); - return false; - } - - if (input?.onReady) { - setSandboxCreatePhase("finalizing"); - setSandboxStage("Finalizing worker..."); - setSandboxStep("connect", { status: "active", detail: "Applying setup" }); - pushSandboxCreateLog("Applying final worker setup..."); - await input.onReady(); - } - - setSandboxStep("connect", { status: "done", detail: null }); - setSandboxStage("Sandbox ready."); - setCreateWorkspaceOpen(false); - clearSandboxCreateProgress(); - return true; - } finally { - stopListen?.(); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - setSandboxError(message); - setSandboxStage("Sandbox failed"); - return false; - } finally { - setSandboxPreflightBusy(false); - setSandboxCreatePhase("idle"); - } - } - - async function createRemoteWorkspaceFlow(input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - openworkClientToken?: string | null; - openworkHostToken?: string | null; - directory?: string | null; - displayName?: string | null; - manageBusy?: boolean; - closeModal?: boolean; - - // Sandbox lifecycle metadata (desktop-managed) - sandboxBackend?: SandboxBackendType | null; - sandboxRunId?: string | null; - sandboxContainerName?: string | null; - }) { - if (createRemoteInFlight) { - wsDebug("create-remote:dedupe", { - hostUrl: input.openworkHostUrl ?? null, - directory: input.directory ?? null, - }); - return createRemoteInFlight; - } - - const run = (async () => { - const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? ""; - const token = input.openworkToken?.trim() ?? ""; - const directory = input.directory?.trim() ?? ""; - const displayName = input.displayName?.trim() || null; - - if (!hostUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - options.setError(null); - console.log("[workspace] create remote request", { - hostUrl: hostUrl || null, - directory: directory || null, - displayName, - }); - - options.setStartupPreference("server"); - - let remoteType: "openwork" = "openwork"; - let resolvedBaseUrl = ""; - let resolvedDirectory = directory; - let openworkWorkspace: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = undefined; - let resolvedHostUrl = hostUrl; - - options.openworkServer.updateOpenworkServerSettings({ - ...options.openworkServer.openworkServerSettings(), - urlOverride: hostUrl, - token: token || undefined, - }); - - try { - let resolved: Awaited> | null = null; - try { - resolved = await resolveOpenworkHost({ - hostUrl, - token, - directoryHint: directory || null, - }); - } catch (error) { - // Sandbox workers can report healthy before listWorkspaces is fully ready. - // Fall back to host-level OpenCode URL so the worker can still be registered. - if (input.sandboxBackend !== "docker") { - throw error; - } - wsDebug("sandbox:openwork-resolve-fallback:error", { - hostUrl, - message: error instanceof Error ? error.message : safeStringify(error), - }); - } - - if (resolved?.kind === "openwork") { - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory || directory; - openworkWorkspace = resolved.workspace; - resolvedHostUrl = resolved.hostUrl; - resolvedAuth = resolved.auth; - } else if (input.sandboxBackend === "docker" || input.sandboxBackend === "microsandbox") { - resolvedHostUrl = hostUrl; - resolvedBaseUrl = `${hostUrl.replace(/\/+$/, "")}/opencode`; - resolvedDirectory = directory || resolvedDirectory; - resolvedAuth = token ? { token, mode: "openwork" } : undefined; - wsDebug("sandbox:openwork-resolve-fallback:host", { - hostUrl: resolvedHostUrl, - baseUrl: resolvedBaseUrl, - directory: resolvedDirectory, - }); - } else { - options.setError("OpenWork server unavailable. Check the URL and token."); - return false; - } - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - const ok = await connectToServer( - resolvedBaseUrl, - resolvedDirectory || undefined, - { - workspaceType: "remote", - targetRoot: resolvedDirectory ?? "", - reason: "workspace-create-remote", - }, - resolvedAuth, - ); - - if (!ok) { - return false; - } - - const finalDirectory = options.clientDirectory().trim() || resolvedDirectory || ""; - - const manageBusy = input.manageBusy ?? true; - if (manageBusy) { - options.setBusy(true); - options.setBusyLabel("status.creating_workspace"); - options.setBusyStartedAt(Date.now()); - } - - try { - let createdWorkspaceId: string | null = null; - if (isTauriRuntime()) { - const ws = await workspaceCreateRemote({ - baseUrl: resolvedBaseUrl.replace(/\/+$/, ""), - directory: finalDirectory ? finalDirectory : null, - displayName, - remoteType, - openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null, - openworkToken: remoteType === "openwork" ? (token || null) : null, - openworkClientToken: - remoteType === "openwork" ? (input.openworkClientToken?.trim() || null) : null, - openworkHostToken: - remoteType === "openwork" ? (input.openworkHostToken?.trim() || null) : null, - openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null, - openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null, - sandboxBackend: input.sandboxBackend ?? null, - sandboxRunId: input.sandboxRunId ?? null, - sandboxContainerName: input.sandboxContainerName ?? null, - }); - setWorkspaces(ws.workspaces); - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - createdWorkspaceId = nextSelectedId; - syncSelectedWorkspaceId(nextSelectedId); - console.log("[workspace] create remote complete:", nextSelectedId || "none"); - } else { - const workspaceId = `remote:${resolvedBaseUrl}:${finalDirectory}`; - createdWorkspaceId = workspaceId; - const nextWorkspace: WorkspaceInfo = { - id: workspaceId, - name: displayName ?? openworkWorkspace?.name ?? resolvedHostUrl ?? resolvedBaseUrl, - path: "", - preset: "remote", - workspaceType: "remote", - remoteType, - baseUrl: resolvedBaseUrl, - directory: finalDirectory || null, - displayName, - openworkHostUrl: remoteType === "openwork" ? resolvedHostUrl : null, - openworkToken: remoteType === "openwork" ? (token || null) : null, - openworkClientToken: - remoteType === "openwork" ? (input.openworkClientToken?.trim() || null) : null, - openworkHostToken: - remoteType === "openwork" ? (input.openworkHostToken?.trim() || null) : null, - openworkWorkspaceId: remoteType === "openwork" ? openworkWorkspace?.id ?? null : null, - openworkWorkspaceName: remoteType === "openwork" ? openworkWorkspace?.name ?? null : null, - sandboxBackend: input.sandboxBackend ?? null, - sandboxRunId: input.sandboxRunId ?? null, - sandboxContainerName: input.sandboxContainerName ?? null, - }; - - setWorkspaces((prev) => { - const withoutMatch = prev.filter((workspace) => workspace.id !== workspaceId); - return [...withoutMatch, nextWorkspace]; - }); - syncSelectedWorkspaceId(workspaceId); - console.log("[workspace] create remote complete:", workspaceId); - } - - if (createdWorkspaceId) { - setConnectedWorkspaceId(createdWorkspaceId); - } - - setProjectDir(finalDirectory); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - - const closeModal = input.closeModal ?? true; - if (closeModal) { - setCreateWorkspaceOpen(false); - setCreateRemoteWorkspaceOpen(false); - } - if (createdWorkspaceId) { - updateWorkspaceConnectionState(createdWorkspaceId, { status: "connected", message: null }); - } - - await openEmptySession(selectedWorkspaceRoot().trim() || finalDirectory); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - console.log("[workspace] create remote failed:", message); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - if (manageBusy) { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - })(); - - createRemoteInFlight = run; - try { - return await run; - } finally { - if (createRemoteInFlight === run) { - createRemoteInFlight = null; - } - } - } - - async function updateRemoteWorkspaceFlow( - workspaceId: string, - input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - openworkClientToken?: string | null; - openworkHostToken?: string | null; - directory?: string | null; - displayName?: string | null; - }, - ) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace || workspace.workspaceType !== "remote") return false; - - const remoteType = normalizeRemoteType(workspace.remoteType); - if (remoteType !== "openwork") { - options.setError("Only OpenWork remote workers can be edited."); - return false; - } - - const hostUrl = - normalizeOpenworkServerUrl( - input.openworkHostUrl ?? workspace.openworkHostUrl ?? workspace.baseUrl ?? "", - ) ?? ""; - const token = - input.openworkToken?.trim() ?? - workspace.openworkToken?.trim() ?? - options.openworkServer.openworkServerSettings().token ?? - ""; - const directory = input.directory?.trim() ?? ""; - const displayName = input.displayName?.trim() || null; - - if (!hostUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - options.setError(null); - options.setStartupPreference("server"); - - let resolvedBaseUrl = ""; - let resolvedDirectory = directory; - let openworkWorkspace: OpenworkWorkspaceInfo | null = null; - let resolvedAuth: OpencodeAuth | undefined = undefined; - let resolvedHostUrl = hostUrl; - - options.openworkServer.updateOpenworkServerSettings({ - ...options.openworkServer.openworkServerSettings(), - urlOverride: hostUrl, - token: token || undefined, - }); - - try { - const resolved = await resolveOpenworkHost({ - hostUrl, - token, - workspaceId: workspace.openworkWorkspaceId ?? null, - directoryHint: directory || null, - }); - if (resolved.kind !== "openwork") { - options.setError("OpenWork server unavailable. Check the URL and token."); - return false; - } - resolvedBaseUrl = resolved.opencodeBaseUrl; - resolvedDirectory = resolved.directory || directory; - openworkWorkspace = resolved.workspace; - resolvedHostUrl = resolved.hostUrl; - resolvedAuth = resolved.auth; - } catch (error) { - const message = error instanceof Error ? error.message : safeStringify(error); - options.setError(addOpencodeCacheHint(message)); - return false; - } - - if (!resolvedBaseUrl) { - options.setError(t("app.error.remote_base_url_required", currentLocale())); - return false; - } - - const isActive = connectedWorkspaceId() === id; - const finalDirectory = resolvedDirectory || ""; - - if (isActive) { - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - const ok = await connectToServer( - resolvedBaseUrl, - finalDirectory || undefined, - { - workspaceId: id, - workspaceType: "remote", - targetRoot: finalDirectory ?? "", - reason: "workspace-edit-remote", - }, - resolvedAuth, - ); - if (!ok) { - updateWorkspaceConnectionState(id, { - status: "error", - message: "Failed to connect to worker.", - }); - return false; - } - } - - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateRemote({ - workspaceId: id, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: finalDirectory ? finalDirectory : null, - displayName, - openworkHostUrl: resolvedHostUrl, - openworkToken: token ? token : null, - openworkClientToken: - input.openworkClientToken?.trim() || workspace.openworkClientToken?.trim() || null, - openworkHostToken: - input.openworkHostToken?.trim() || workspace.openworkHostToken?.trim() || null, - openworkWorkspaceId: openworkWorkspace?.id ?? workspace.openworkWorkspaceId ?? null, - openworkWorkspaceName: openworkWorkspace?.name ?? workspace.openworkWorkspaceName ?? null, - }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - } catch { - // ignore - } - } else { - setWorkspaces((prev) => - prev.map((item) => - item.id === id - ? { - ...item, - remoteType: "openwork", - baseUrl: resolvedBaseUrl, - directory: finalDirectory ? finalDirectory : null, - displayName, - openworkHostUrl: resolvedHostUrl, - openworkToken: token ? token : null, - openworkClientToken: - input.openworkClientToken?.trim() || item.openworkClientToken?.trim() || null, - openworkHostToken: - input.openworkHostToken?.trim() || item.openworkHostToken?.trim() || null, - openworkWorkspaceId: openworkWorkspace?.id ?? item.openworkWorkspaceId ?? null, - openworkWorkspaceName: openworkWorkspace?.name ?? item.openworkWorkspaceName ?? null, - } - : item, - ), - ); - } - - if (isActive) { - setProjectDir(finalDirectory); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - } - - return true; - } - - async function forgetWorkspace(workspaceId: string) { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - const id = workspaceId.trim(); - if (!id) return; - const workspace = workspaces().find((entry) => entry.id === id) ?? null; - - console.log("[workspace] forget", { id }); - - try { - const previousActive = selectedWorkspaceId(); - const openworkWorkspace = workspace?.workspaceType === "local" ? await findOpenworkWorkspaceByPath(workspace.path) : null; - const ws = openworkWorkspace - ? await openworkWorkspace.client.deleteWorkspace(openworkWorkspace.workspaceId).then((response) => ({ - activeId: response.activeId ?? "", - workspaces: response.workspaces ?? response.items, - })) - : await workspaceForget(id); - - if (openworkWorkspace && isTauriRuntime()) { - try { - await workspaceForget(id); - } catch { - // ignore desktop mirror failures here - } - } - - if (openworkWorkspace) { - applyServerLocalWorkspaces(ws.workspaces, ws.activeId); - } else { - setWorkspaces(ws.workspaces); - } - clearWorkspaceConnectionState(id); - - if (!openworkWorkspace) { - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [selectedWorkspaceId()], ws)); - } - - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [selectedWorkspaceId()], ws); - const selected = ws.workspaces.find((w) => w.id === nextSelectedId) ?? null; - // Snapshot the runtime root before the optimistic selected-workspace update - // so activateWorkspace can still detect a real workspace transition. - const prevProjectDir = resolveCurrentRuntimeRoot(); - if (selected) { - setProjectDir(selected.workspaceType === "remote" ? selected.directory?.trim() ?? "" : selected.path); - } - - if (nextSelectedId && nextSelectedId !== previousActive) { - await activateWorkspace(nextSelectedId, { prevProjectDir }); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function recoverWorkspace(workspaceId: string) { - const id = workspaceId.trim(); - if (!id) return false; - if (connectingWorkspaceId() === id) return false; - - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - const reconnect = async () => { - if (connectedWorkspaceId() === id) { - return await activateWorkspace(id); - } - return await testWorkspaceConnection(id); - }; - - setConnectingWorkspaceId(id); - options.setError(null); - - try { - updateWorkspaceConnectionState(id, { status: "connecting", message: null }); - - if (workspace.workspaceType !== "remote") { - return Boolean(await reconnect()); - } - - const isSandboxWorkspace = - workspace.sandboxBackend === "docker" || - workspace.sandboxBackend === "microsandbox" || - Boolean(workspace.sandboxContainerName?.trim()); - - if (!isSandboxWorkspace) { - return Boolean(await reconnect()); - } - - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - updateWorkspaceConnectionState(id, { - status: "error", - message: t("app.error.tauri_required", currentLocale()), - }); - return false; - } - - const workspacePath = workspace.directory?.trim() || workspace.path?.trim() || ""; - if (!workspacePath) { - const message = "Worker folder is missing. Open Edit connection and try again."; - options.setError(message); - updateWorkspaceConnectionState(id, { status: "error", message }); - return false; - } - - const doctor = await refreshSandboxDoctor(); - if (!doctor?.ready) { - const detail = - doctor?.error?.trim() || - "Docker needs to be running before we can get this worker back online."; - throw new Error(detail); - } - - const host = await orchestratorStartDetached({ - workspacePath, - sandboxBackend: "docker", - runId: workspace.sandboxRunId?.trim() || null, - openworkToken: - workspace.openworkClientToken?.trim() || - workspace.openworkToken?.trim() || - options.openworkServer.openworkServerSettings().token?.trim() || - null, - openworkHostToken: workspace.openworkHostToken?.trim() || null, - }); - - const resolved = await resolveOpenworkHost({ - hostUrl: host.openworkUrl, - token: host.ownerToken?.trim() || host.token, - directoryHint: workspacePath, - }); - - if (resolved.kind !== "openwork") { - throw new Error("Worker is still warming up. Try again in a few seconds."); - } - - const updated = await workspaceUpdateRemote({ - workspaceId: id, - remoteType: "openwork", - baseUrl: resolved.opencodeBaseUrl, - directory: resolved.directory || workspacePath, - openworkHostUrl: resolved.hostUrl, - openworkToken: host.ownerToken?.trim() || host.token, - openworkClientToken: host.token, - openworkHostToken: host.hostToken, - openworkWorkspaceId: resolved.workspace.id, - openworkWorkspaceName: resolved.workspace.name ?? workspace.openworkWorkspaceName ?? null, - sandboxBackend: host.sandboxBackend ?? "docker", - sandboxRunId: host.sandboxRunId ?? workspace.sandboxRunId ?? null, - sandboxContainerName: host.sandboxContainerName ?? workspace.sandboxContainerName ?? null, - }); - - setWorkspaces(updated.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(updated.workspaces, [id, selectedWorkspaceId()], updated)); - - const ok = await reconnect(); - if (!ok) { - const message = "Worker restarted, but reconnect failed. Try again in a few seconds."; - updateWorkspaceConnectionState(id, { status: "error", message }); - options.setError(message); - return false; - } - - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - const hint = addOpencodeCacheHint(message); - options.setError(hint); - updateWorkspaceConnectionState(id, { status: "error", message: hint }); - return false; - } finally { - setConnectingWorkspaceId((current) => (current === id ? null : current)); - } - } - - async function pickWorkspaceFolder() { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return null; - } - - try { - const selection = await pickDirectory({ title: t("onboarding.choose_workspace_folder", currentLocale()) }); - const folder = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - - return folder ?? null; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return null; - } - } - - function joinNativePath(base: string, leaf: string) { - const trimmedBase = base.replace(/[\\/]+$/, ""); - if (!trimmedBase) return leaf; - const separator = trimmedBase.includes("\\") ? "\\" : "/"; - return `${trimmedBase}${separator}${leaf}`; - } - - function deriveWorkspaceName(folderPath: string, preset: WorkspacePreset) { - return folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "Worker"; - } - - async function resolveFirstRunWelcomeFolder() { - const base = (await homeDir()).replace(/[\\/]+$/, ""); - return joinNativePath(joinNativePath(base, DEFAULT_WORKSPACE_HOME_FOLDER_NAME), FIRST_RUN_WELCOME_WORKSPACE_NAME); - } - - async function exportWorkspaceConfig(workspaceId?: string) { - if (exportingWorkspaceConfig()) return; - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - const targetId = workspaceId?.trim() || selectedWorkspaceInfo()?.id || ""; - if (!targetId) { - options.setError("Select a worker to export"); - return; - } - const target = workspaces().find((ws) => ws.id === targetId) ?? null; - if (!target) { - options.setError("Unknown worker"); - return; - } - if (target.workspaceType === "remote") { - options.setError("Export is only supported for local workers"); - return; - } - - setExportingWorkspaceConfig(true); - options.setError(null); - - try { - const nameBase = (target.displayName || target.name || "worker") - .toLowerCase() - .replace(/[^a-z0-9-_]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60); - const dateStamp = new Date().toISOString().slice(0, 10); - const fileName = `openwork-${nameBase || "worker"}-${dateStamp}.openwork-workspace`; - const downloads = await downloadDir().catch(() => null); - const defaultPath = downloads ? `${downloads}/${fileName}` : fileName; - - const outputPath = await saveFile({ - title: "Export worker config", - defaultPath, - filters: [{ name: "OpenWork Worker", extensions: ["openwork-workspace", "zip"] }], - }); - - if (!outputPath) { - return; - } - - await workspaceExportConfig({ - workspaceId: target.id, - outputPath, - }); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setExportingWorkspaceConfig(false); - } - } - - async function importWorkspaceConfig() { - if (importingWorkspaceConfig()) return; - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return; - } - - setImportingWorkspaceConfig(true); - options.setError(null); - - try { - const selection = await pickFile({ - title: "Import worker config", - filters: [{ name: "OpenWork Worker", extensions: ["openwork-workspace", "zip"] }], - }); - const filePath = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - if (!filePath) return; - - const target = await pickDirectory({ - title: "Choose a worker folder", - }); - const folder = - typeof target === "string" ? target : Array.isArray(target) ? target[0] : null; - if (!folder) return; - - const resolvedFolder = await resolveWorkspacePath(folder); - if (!resolvedFolder) { - options.setError(t("app.error.choose_folder", currentLocale())); - return; - } - - const ws = await workspaceImportConfig({ - archivePath: filePath, - targetDir: resolvedFolder, - }); - - setWorkspaces(ws.workspaces); - const nextSelectedId = pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws); - syncSelectedWorkspaceId(nextSelectedId); - setCreateWorkspaceOpen(false); - setCreateRemoteWorkspaceOpen(false); - - const opened = await activateFreshLocalWorkspace(nextSelectedId || null, resolvedFolder); - if (!opened) { - return; - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setImportingWorkspaceConfig(false); - } - } - - async function startHost(optionsOverride?: { workspacePath?: string; navigate?: boolean }) { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - - const overrideWorkspacePath = optionsOverride?.workspacePath?.trim() ?? ""; - if (selectedWorkspaceInfo()?.workspaceType === "remote" && !overrideWorkspacePath) { - options.setError(t("app.error.host_requires_local", currentLocale())); - return false; - } - - const dir = (overrideWorkspacePath || selectedWorkspacePath() || projectDir()).trim(); - if (!dir) { - options.setError(t("app.error.pick_workspace_folder", currentLocale())); - return false; - } - - try { - const source = options.engineSource(); - const result = await engineDoctor({ - preferSidecar: source === "sidecar", - opencodeBinPath: source === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - }); - setEngineDoctorResult(result); - setEngineDoctorCheckedAt(Date.now()); - - if (!result.found) { - options.setError( - options.isWindowsPlatform() - ? "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version for Windows or bundle opencode.exe with OpenWork, then restart. If it is installed, ensure `opencode.exe` is on PATH (try `opencode --version` in PowerShell)." - : "OpenCode CLI not found. Install the OpenWork-pinned OpenCode version, then retry.", - ); - return false; - } - - if (!result.supportsServe) { - const serveDetails = [result.serveHelpStdout, result.serveHelpStderr] - .filter((value) => value && value.trim()) - .join("\n\n"); - const suffix = serveDetails ? `\n\nServe output:\n${serveDetails}` : ""; - options.setError( - `OpenCode CLI is installed, but \`opencode serve\` is unavailable. Update to the OpenWork-pinned OpenCode version and retry.${suffix}` - ); - return false; - } - } catch (e) { - setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); - } - - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.starting_engine"); - options.setBusyStartedAt(Date.now()); - - try { - setProjectDir(dir); - if (!authorizedDirs().length) { - setAuthorizedDirs([dir]); - } - - const info = await engineStart(dir, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime: resolveEngineRuntime(), - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(info); - - const username = info.opencodeUsername?.trim() ?? ""; - const password = info.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (info.baseUrl) { - const ok = await connectToServer( - info.baseUrl, - dir, - { workspaceType: "local", targetRoot: dir, reason: "host-start" }, - auth, - { navigate: optionsOverride?.navigate ?? true }, - ); - if (!ok) return false; - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function updateWorkspaceDisplayName(workspaceId: string, displayName: string | null) { - const id = workspaceId.trim(); - if (!id) return false; - const workspace = workspaces().find((item) => item.id === id) ?? null; - if (!workspace) return false; - - const nextDisplayName = displayName?.trim() || null; - options.setError(null); - - const openworkWorkspace = workspace.workspaceType === "local" - ? await findOpenworkWorkspaceByPath(workspace.path) - : null; - - if (openworkWorkspace) { - try { - const ws = await openworkWorkspace.client.updateWorkspaceDisplayName(openworkWorkspace.workspaceId, nextDisplayName); - if (isTauriRuntime()) { - try { - await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName }); - } catch { - // ignore desktop mirror failures here - } - } - applyServerLocalWorkspaces(ws.workspaces, ws.activeId); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } - } - - if (isTauriRuntime()) { - try { - const ws = await workspaceUpdateDisplayName({ workspaceId: id, displayName: nextDisplayName }); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [id, selectedWorkspaceId()], ws)); - updateWorkspaceConnectionState(id, { status: "connected", message: null }); - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } - } - - setWorkspaces((prev) => - prev.map((entry) => - entry.id === id - ? { - ...entry, - displayName: nextDisplayName, - name: nextDisplayName ?? entry.name, - } - : entry - ) - ); - return true; - } - - const openRenameWorkspace = (workspaceId: string) => { - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (!workspace) return; - setRenameWorkspaceId(workspaceId); - setRenameWorkspaceName( - workspace.displayName?.trim() || - workspace.openworkWorkspaceName?.trim() || - workspace.name?.trim() || - "", - ); - setRenameWorkspaceOpen(true); - }; - - const closeRenameWorkspace = () => { - if (renameWorkspaceBusy()) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - }; - - const saveRenameWorkspace = async () => { - const workspaceId = renameWorkspaceId(); - if (!workspaceId) return; - const nextName = renameWorkspaceName().trim(); - if (!nextName) return; - if (renameWorkspaceBusy()) return; - - setRenameWorkspaceBusy(true); - options.setError(null); - try { - const ok = await updateWorkspaceDisplayName(workspaceId, nextName); - if (!ok) return; - setRenameWorkspaceOpen(false); - setRenameWorkspaceId(null); - setRenameWorkspaceName(""); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - setRenameWorkspaceBusy(false); - } - }; - - const closeWorkspaceConnectionSettings = () => { - setEditRemoteWorkspaceOpen(false); - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - }; - - const saveWorkspaceConnectionSettings = async (input: { - openworkHostUrl?: string | null; - openworkToken?: string | null; - directory?: string | null; - displayName?: string | null; - }) => { - const workspaceId = editRemoteWorkspaceId(); - if (!workspaceId) return; - setEditRemoteWorkspaceError(null); - try { - const ok = await updateRemoteWorkspaceFlow(workspaceId, input); - if (ok) { - closeWorkspaceConnectionSettings(); - return; - } - setEditRemoteWorkspaceError(t("app.error_connection_failed_url", currentLocale())); - options.setError(null); - } catch (e) { - const message = e instanceof Error ? e.message : t("app.error_connection_failed", currentLocale()); - setEditRemoteWorkspaceError(message); - options.setError(null); - } - }; - - const openWorkspaceConnectionSettings = (workspaceId: string) => { - const workspace = workspaces().find((item) => item.id === workspaceId) ?? null; - if (workspace?.workspaceType === "remote") { - setEditRemoteWorkspaceId(workspace.id); - setEditRemoteWorkspaceError(null); - setEditRemoteWorkspaceOpen(true); - return; - } - setEditRemoteWorkspaceId(null); - setEditRemoteWorkspaceError(null); - options.setSettingsTab("advanced"); - options.setView("settings"); - }; - - async function stopHost() { - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.disconnecting"); - options.setBusyStartedAt(Date.now()); - - try { - if (isTauriRuntime()) { - const info = await engineStop(); - setEngine(info); - } - - setEngineAuth(null); - - options.setClient(null); - options.setConnectedVersion(null); - setConnectedWorkspaceId(null); - if (isTauriRuntime()) { - try { - await workspaceSetRuntimeActive(null); - } catch { - // ignore - } - } - options.setSelectedSessionId(null); - options.setMessages([]); - options.setTodos([]); - options.setPendingPermissions([]); - options.setSessionStatusById({}); - options.setSseConnected(false); - - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - - options.setView("session"); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function reloadWorkspaceEngine() { - if (!isTauriRuntime()) { - options.setError("Reloading the engine requires the desktop app."); - return false; - } - - if (selectedWorkspaceDisplay().workspaceType !== "local") { - options.setError("Reload is only available for local workers."); - return false; - } - - const root = selectedWorkspacePath().trim(); - if (!root) { - options.setError("Pick a worker folder first."); - return false; - } - - options.setError(null); - options.setBusy(true); - options.setBusyLabel("status.reloading_engine"); - options.setBusyStartedAt(Date.now()); - - try { - const runtime = engine()?.runtime ?? resolveEngineRuntime(); - if (runtime === "openwork-orchestrator") { - await orchestratorInstanceDispose(root); - await orchestratorWorkspaceActivate({ - workspacePath: root, - name: selectedWorkspaceInfo()?.displayName?.trim() || selectedWorkspaceInfo()?.name?.trim() || null, - }); - - const nextInfo = await engineInfo(); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - const ok = await connectToServer( - nextInfo.baseUrl, - root, - { workspaceType: "local", targetRoot: root, reason: "engine-reload-orchestrator" }, - auth, - ); - if (!ok) { - options.setError("Failed to reconnect after reload"); - return false; - } - } - - return true; - } - - const info = await engineStop(); - setEngine(info); - - const nextInfo = await engineStart(root, { - preferSidecar: options.engineSource() === "sidecar", - opencodeBinPath: - options.engineSource() === "custom" ? options.engineCustomBinPath?.().trim() || null : null, - opencodeEnableExa: options.opencodeEnableExa?.() ?? false, - openworkRemoteAccess: options.openworkServer.openworkServerSettings().remoteAccessEnabled === true, - runtime, - workspacePaths: resolveWorkspacePaths(), - }); - setEngine(nextInfo); - - const username = nextInfo.opencodeUsername?.trim() ?? ""; - const password = nextInfo.opencodePassword?.trim() ?? ""; - const auth = username && password ? { username, password } : undefined; - setEngineAuth(auth ?? null); - - if (nextInfo.baseUrl) { - const ok = await connectToServer( - nextInfo.baseUrl, - root, - { workspaceType: "local", targetRoot: root, reason: "engine-reload" }, - auth, - ); - if (!ok) { - options.setError("Failed to reconnect after reload"); - return false; - } - } - - return true; - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - return false; - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - async function onInstallEngine() { - options.setError(null); - setEngineInstallLogs(null); - options.setBusy(true); - options.setBusyLabel("status.installing_opencode"); - options.setBusyStartedAt(Date.now()); - - try { - const result = await engineInstall(); - const combined = `${result.stdout}${result.stderr ? `\n${result.stderr}` : ""}`.trim(); - setEngineInstallLogs(combined || null); - - if (!result.ok) { - options.setError(result.stderr.trim() || t("app.error.install_failed", currentLocale())); - } - - await refreshEngineDoctor(); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } finally { - options.setBusy(false); - options.setBusyLabel(null); - options.setBusyStartedAt(null); - } - } - - function normalizeRoots(list: string[]) { - const out: string[] = []; - for (const entry of list) { - const trimmed = entry.trim().replace(/\/+$/, ""); - if (!trimmed) continue; - if (!out.includes(trimmed)) out.push(trimmed); - } - return out; - } - - async function resolveWorkspacePath(input: string) { - const trimmed = input.trim(); - if (!trimmed) return ""; - if (!isTauriRuntime()) return trimmed; - - if (trimmed === "~") { - try { - return (await homeDir()).replace(/[\\/]+$/, ""); - } catch { - return trimmed; - } - } - - if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { - try { - const home = (await homeDir()).replace(/[\\/]+$/, ""); - return `${home}${trimmed.slice(1)}`; - } catch { - return trimmed; - } - } - - return trimmed; - } - - async function persistAuthorizedRoots(nextRoots: string[]) { - if (!isTauriRuntime()) return; - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const root = selectedWorkspacePath().trim(); - if (!root) return; - - const existing = workspaceConfig(); - const cfg: WorkspaceOpenworkConfig = { - version: existing?.version ?? 1, - workspace: existing?.workspace ?? null, - authorizedRoots: nextRoots, - blueprint: existing?.blueprint ?? null, - reload: existing?.reload ?? null, - }; - - const persistedViaServer = await persistWorkspaceConfigToOpenworkServer(cfg).catch(() => false); - if (!persistedViaServer) { - await workspaceOpenworkWrite({ workspacePath: root, config: cfg }); - } - setWorkspaceConfig(cfg); - } - - async function addAuthorizedDir() { - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const next = newAuthorizedDir().trim(); - if (!next) return; - - const roots = normalizeRoots([...authorizedDirs(), next]); - setAuthorizedDirs(roots); - setNewAuthorizedDir(""); - - try { - await persistAuthorizedRoots(roots); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function addAuthorizedDirFromPicker(optionsOverride?: { persistToWorkspace?: boolean }) { - if (!isTauriRuntime()) return; - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - - try { - const selection = await pickDirectory({ title: t("onboarding.authorize_folder", currentLocale()) }); - const folder = - typeof selection === "string" ? selection : Array.isArray(selection) ? selection[0] : null; - if (!folder) return; - - const roots = normalizeRoots([...authorizedDirs(), folder]); - setAuthorizedDirs(roots); - - if (optionsOverride?.persistToWorkspace) { - await persistAuthorizedRoots(roots); - } - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - async function removeAuthorizedDir(dir: string) { - if (selectedWorkspaceInfo()?.workspaceType === "remote") return; - const roots = normalizeRoots(authorizedDirs().filter((root) => root !== dir)); - setAuthorizedDirs(roots); - - try { - await persistAuthorizedRoots(roots); - } catch (e) { - const message = e instanceof Error ? e.message : safeStringify(e); - options.setError(addOpencodeCacheHint(message)); - } - } - - function removeAuthorizedDirAtIndex(index: number) { - const roots = authorizedDirs(); - const target = roots[index]; - if (target) { - void removeAuthorizedDir(target); - } - } - - function restoreLastSession() { - const map = options.readLastSessionByWorkspace?.() ?? {}; - const workspaceId = selectedWorkspaceId().trim(); - if (!workspaceId) return; - const lastSessionId = map[workspaceId]?.trim(); - if (!lastSessionId) return; - if (options.selectedSessionId() === lastSessionId) return; - options.setSelectedSessionId(lastSessionId); - options.setView("session", lastSessionId); - void options.selectSession(lastSessionId, { skipHealthCheck: true, source: "restore-last-session" }); - } - - async function bootstrapOnboarding() { - const enterPhase = (phase: BootPhase, detail?: Record) => { - options.onBootPhaseChange?.(phase, detail); - options.onStartupTrace?.(`phase:${phase}`, detail); - }; - const markBranch = (branch: StartupBranch, detail?: Record) => { - options.onStartupBranch?.(branch, detail); - options.onStartupTrace?.(`branch:${branch}`, detail); - }; - - const startupPref = readStartupPreference(); - let info: EngineInfo | null = null; - - if (isTauriRuntime()) { - enterPhase("workspaceBootstrap", { source: "workspace_bootstrap" }); - try { - const ws = await workspaceBootstrap(); - setWorkspaces(ws.workspaces); - syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws)); - } catch (error) { - options.onStartupTrace?.("workspace_bootstrap:error", { - error: error instanceof Error ? error.message : safeStringify(error), - }); - } - } - - enterPhase("engineProbe", { source: "ts-probe" }); - void refreshEngine().catch(() => undefined); - info = engine(); - void refreshEngineDoctor().catch(() => undefined); - - if (isTauriRuntime() && workspaces().length === 0) { - markBranch("firstRunNoWorkspace", { startupPref }); - options.setStartupPreference("local"); - const welcomeFolder = await resolveFirstRunWelcomeFolder(); - const ok = await createWorkspaceFlow("starter", welcomeFolder); - if (!ok) { - options.setOnboardingStep("local"); - } - enterPhase("ready", { reason: "first-run-no-workspace" }); - return; - } - - if (isTauriRuntime()) { - const active = workspaces().find((w) => w.id === selectedWorkspaceId()) ?? null; - if (active) { - if (active.workspaceType === "remote") { - setProjectDir(active.directory?.trim() ?? ""); - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([]); - if (active.baseUrl) { - options.setBaseUrl(active.baseUrl); - } - } else { - setProjectDir(active.path); - try { - const cfg = await workspaceOpenworkRead({ workspacePath: active.path }); - setWorkspaceConfig(cfg); - setWorkspaceConfigLoaded(true); - const roots = Array.isArray(cfg.authorizedRoots) ? cfg.authorizedRoots : []; - setAuthorizedDirs(roots.length ? roots : [active.path]); - } catch { - setWorkspaceConfig(null); - setWorkspaceConfigLoaded(true); - setAuthorizedDirs([active.path]); - } - - } - } - } - - const localEngine = info ?? engine(); - if (localEngine?.baseUrl) { - options.setBaseUrl(localEngine.baseUrl); - } - - const activeWorkspace = selectedWorkspaceInfo(); - if (isTauriRuntime() && !localEngine?.baseUrl) { - const firstLocalWorkspace = workspaces().find((workspace) => workspace.workspaceType === "local"); - if (firstLocalWorkspace?.path?.trim()) { - enterPhase("engineStartOrConnect", { source: "bootstrap-first-local-host-start" }); - await startHost({ workspacePath: firstLocalWorkspace.path.trim(), navigate: false }).catch(() => false); - info = engine(); - } - } - - if (activeWorkspace?.workspaceType === "remote") { - markBranch("remoteWorkspace", { workspaceId: activeWorkspace.id }); - options.setStartupPreference("server"); - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "remote-activate" }); - const ok = await activateWorkspace(activeWorkspace.id); - if (!ok) { - options.setOnboardingStep("server"); - } else { - enterPhase("sessionIndexReady", { source: "remote-activate" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - } - enterPhase("ready", { reason: "remote-workspace-branch" }); - return; - } - - if (startupPref) { - options.setStartupPreference(startupPref); - } - - if (startupPref === "server") { - markBranch("serverPreference", { startupPref }); - options.setOnboardingStep("server"); - enterPhase("ready", { reason: "server-preference" }); - return; - } - - if (selectedWorkspacePath().trim()) { - options.setStartupPreference("local"); - - if (localEngine?.running && localEngine.baseUrl) { - markBranch("localAttachExisting", { - baseUrl: localEngine.baseUrl, - }); - const bootstrapRoot = selectedWorkspacePath().trim() || localEngine.projectDir?.trim() || ""; - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "bootstrap-local-attach" }); - const ok = await connectToServer( - localEngine.baseUrl, - bootstrapRoot || undefined, - { workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" }, - engineAuth() ?? undefined, - ); - if (!ok) { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - enterPhase("error", { reason: "bootstrap-local-connect-failed" }); - return; - } - enterPhase("sessionIndexReady", { source: "bootstrap-local-attach" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - enterPhase("ready", { reason: "bootstrap-local-attach" }); - return; - } - - markBranch("localHostStart", { workspacePath: selectedWorkspacePath().trim() }); - options.setOnboardingStep("connecting"); - enterPhase("engineStartOrConnect", { source: "bootstrap-local-host-start" }); - const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() }); - if (!ok) { - options.setOnboardingStep("local"); - enterPhase("error", { reason: "bootstrap-local-host-start-failed" }); - return; - } - enterPhase("sessionIndexReady", { source: "bootstrap-local-host-start" }); - restoreLastSession(); - enterPhase("firstSessionReady", { source: "restore-last-session" }); - enterPhase("ready", { reason: "bootstrap-local-host-start" }); - return; - } - - if (startupPref === "local") { - markBranch("localPreference", { startupPref }); - options.setOnboardingStep("local"); - enterPhase("ready", { reason: "local-preference" }); - return; - } - - markBranch("welcome", { startupPref: startupPref ?? null }); - options.setOnboardingStep("welcome"); - enterPhase("ready", { reason: "default-welcome" }); - } - - function onSelectStartup(nextPref: StartupPreference) { - if (options.rememberStartupChoice()) { - writeStartupPreference(nextPref); - } - options.setStartupPreference(nextPref); - options.setOnboardingStep(nextPref === "local" ? "local" : "server"); - } - - function onBackToWelcome() { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - } - - async function onStartHost() { - options.setStartupPreference("local"); - options.setOnboardingStep("connecting"); - const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() }); - if (!ok) { - options.setOnboardingStep("local"); - } - } - - async function onAttachHost() { - options.setStartupPreference("local"); - options.setOnboardingStep("connecting"); - const attachRoot = selectedWorkspacePath().trim() || engine()?.projectDir?.trim() || ""; - const ok = await connectToServer( - engine()?.baseUrl ?? "", - attachRoot || undefined, - { workspaceType: "local", targetRoot: attachRoot, reason: "attach-local" }, - engineAuth() ?? undefined, - ); - if (!ok) { - options.setStartupPreference(null); - options.setOnboardingStep("welcome"); - } - } - - async function onConnectClient() { - options.setStartupPreference("server"); - options.setOnboardingStep("connecting"); - const settings = options.openworkServer.openworkServerSettings(); - const ok = await createRemoteWorkspaceFlow({ - openworkHostUrl: settings.urlOverride ?? null, - openworkToken: settings.token ?? null, - directory: options.clientDirectory().trim() ? options.clientDirectory().trim() : null, - displayName: null, - }); - if (!ok) { - options.setOnboardingStep("server"); - } - } - - function onRememberStartupToggle() { - if (typeof window === "undefined") return; - const next = !options.rememberStartupChoice(); - options.setRememberStartupChoice(next); - try { - if (next) { - const current = options.startupPreference(); - if (current === "local" || current === "server") { - writeStartupPreference(current); - } - } else { - clearStartupPreference(); - } - } catch { - // ignore - } - } - - return { - engine, - engineDoctorResult, - engineDoctorCheckedAt, - engineInstallLogs, - sandboxDoctorResult, - sandboxDoctorCheckedAt, - sandboxDoctorBusy, - sandboxPreflightBusy, - sandboxCreatePhase, - projectDir, - workspaces, - selectedWorkspaceId, - authorizedDirs, - newAuthorizedDir, - workspaceConfig, - workspaceConfigLoaded, - createWorkspaceOpen, - createRemoteWorkspaceOpen, - editRemoteWorkspaceOpen, - editRemoteWorkspaceId, - editRemoteWorkspaceError, - editRemoteWorkspaceDefaults, - renameWorkspaceOpen, - renameWorkspaceId, - renameWorkspaceName, - renameWorkspaceBusy, - setRenameWorkspaceName, - connectingWorkspaceId, - connectedWorkspaceId, - runtimeWorkspaceId, - runtimeWorkspaceConfig, - workspaceConnectionStateById, - exportingWorkspaceConfig, - importingWorkspaceConfig, - selectedWorkspaceInfo, - selectedWorkspaceDisplay, - selectedWorkspacePath, - selectedWorkspaceRoot, - runtimeWorkspaceRoot, - setCreateWorkspaceOpen, - setCreateRemoteWorkspaceOpen, - setProjectDir, - setAuthorizedDirs, - setNewAuthorizedDir, - setWorkspaceConfig, - setWorkspaceConfigLoaded, - setWorkspaces, - clearSelectedSessionSurface, - syncSelectedWorkspaceId: syncSelectedWorkspaceId, - workspaceRootForId, - selectWorkspace, - switchWorkspace, - refreshEngine, - refreshEngineDoctor, - activateWorkspace, - ensureRuntimeWorkspaceId, - testWorkspaceConnection, - connectToServer, - createWorkspaceFlow, - createSandboxFlow, - createRemoteWorkspaceFlow, - updateRemoteWorkspaceFlow, - updateWorkspaceDisplayName, - openRenameWorkspace, - closeRenameWorkspace, - saveRenameWorkspace, - openWorkspaceConnectionSettings, - closeWorkspaceConnectionSettings, - saveWorkspaceConnectionSettings, - forgetWorkspace, - recoverWorkspace, - pickWorkspaceFolder, - exportWorkspaceConfig, - importWorkspaceConfig, - startHost, - stopHost, - reloadWorkspaceEngine, - refreshRuntimeWorkspaceConfig, - bootstrapOnboarding, - onSelectStartup, - onBackToWelcome, - onStartHost, - onAttachHost, - onConnectClient, - onRememberStartupToggle, - onInstallEngine, - addAuthorizedDir, - addAuthorizedDirFromPicker, - removeAuthorizedDir, - removeAuthorizedDirAtIndex, - setEngineInstallLogs, - refreshSandboxDoctor, - sandboxCreateProgress, - lastSandboxCreateProgress, - clearSandboxCreateProgress, - workspaceDebugEvents, - clearWorkspaceDebugEvents, - }; -} diff --git a/apps/app/src/app/entry.tsx b/apps/app/src/app/entry.tsx deleted file mode 100644 index 9b51cef5..00000000 --- a/apps/app/src/app/entry.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import App from "./app"; -import { DenAuthProvider } from "./cloud/den-auth-provider"; -import { DesktopConfigProvider } from "./cloud/desktop-config-provider"; -import { GlobalSDKProvider } from "./context/global-sdk"; -import { GlobalSyncProvider } from "./context/global-sync"; -import { LocalProvider } from "./context/local"; -import { ServerProvider } from "./context/server"; -import { isWebDeployment } from "./lib/openwork-deployment"; -import { isTauriRuntime } from "./utils"; - -export default function AppEntry() { - const defaultUrl = (() => { - // Desktop app connects to the local OpenCode engine. - if (isTauriRuntime()) return "http://127.0.0.1:4096"; - - // When running the web UI against an OpenWork server (e.g. Docker dev stack), - // use the server's `/opencode` proxy instead of loopback. - const openworkUrl = - typeof import.meta.env?.VITE_OPENWORK_URL === "string" - ? import.meta.env.VITE_OPENWORK_URL.trim() - : ""; - if (openworkUrl) { - return `${openworkUrl.replace(/\/+$/, "")}/opencode`; - } - - // When the hosted web deployment is served by the OpenWork server, - // OpenCode is proxied at same-origin `/opencode`. - if (isWebDeployment() && import.meta.env.PROD && typeof window !== "undefined") { - return `${window.location.origin}/opencode`; - } - - // Dev fallback (Vite) - allow overriding for remote debugging. - const envUrl = - typeof import.meta.env?.VITE_OPENCODE_URL === "string" - ? import.meta.env.VITE_OPENCODE_URL.trim() - : ""; - return envUrl || "http://127.0.0.1:4096"; - })(); - - return ( - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/app/extensions/provider.tsx b/apps/app/src/app/extensions/provider.tsx deleted file mode 100644 index b53d0077..00000000 --- a/apps/app/src/app/extensions/provider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; - -import type { ExtensionsStore } from "../context/extensions"; - -const ExtensionsContext = createContext(); - -export function ExtensionsProvider(props: ParentProps<{ store: ExtensionsStore }>) { - return ( - - {props.children} - - ); -} - -export function useExtensions() { - const context = useContext(ExtensionsContext); - if (!context) { - throw new Error("useExtensions must be used within an ExtensionsProvider"); - } - return context; -} diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index e31eeda5..62773a79 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -44,6 +44,10 @@ body { height: 100%; } +#root { + height: 100%; +} + html { font-size: var(--openwork-font-size, 16px); } diff --git a/apps/app/src/app/lib/den-template-cache.ts b/apps/app/src/app/lib/den-template-cache.ts index 13b56645..b130b9c7 100644 --- a/apps/app/src/app/lib/den-template-cache.ts +++ b/apps/app/src/app/lib/den-template-cache.ts @@ -1,5 +1,3 @@ -import { createSignal } from "solid-js"; - import { createDenClient, type DenTemplate } from "./den"; type DenTemplateCacheKeyInput = { @@ -17,7 +15,6 @@ type DenTemplateCacheEntry = { }; const templateCache = new Map(); -const [templateCacheVersion, setTemplateCacheVersion] = createSignal(0); function getCacheKey(input: DenTemplateCacheKeyInput): string | null { const baseUrl = input.baseUrl?.trim() ?? ""; @@ -51,7 +48,6 @@ function readEntry(key: string | null): DenTemplateCacheEntry { function writeEntry(key: string, next: DenTemplateCacheEntry) { templateCache.set(key, next); - setTemplateCacheVersion((value) => value + 1); } function toMessage(error: unknown, fallback: string) { @@ -59,7 +55,6 @@ function toMessage(error: unknown, fallback: string) { } export function readDenTemplateCacheSnapshot(input: DenTemplateCacheKeyInput) { - templateCacheVersion(); const key = getCacheKey(input); const entry = readEntry(key); return { @@ -127,5 +122,4 @@ export async function loadDenTemplateCache( export function clearDenTemplateCache() { if (templateCache.size === 0) return; templateCache.clear(); - setTemplateCacheVersion((value) => value + 1); } diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 6cebc3bd..c1448ec0 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -1,5 +1,16 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import { normalizeDesktopConfig, type DesktopConfig as SharedDesktopConfig } from "@openwork/types/den/desktop-app-restrictions"; +import { + normalizeDesktopConfig, + type DesktopConfig as SharedDesktopConfig, +} from "@openwork/types/den/desktop-app-restrictions"; + +// Re-export the shared schema under the local alias so React consumers +// (e.g. the cloud domain's desktop-config provider) can import it alongside +// the helpers they need. Solid references it internally only; the React +// port wants it as part of the public surface of this module. +export type { SharedDesktopConfig }; +export { normalizeDesktopConfig }; + import { isDesktopDeployment } from "./openwork-deployment"; import { dispatchDenSettingsChanged, diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts index ee24bb8f..c95c6ace 100644 --- a/apps/app/src/app/lib/opencode.ts +++ b/apps/app/src/app/lib/opencode.ts @@ -266,6 +266,32 @@ const resolveAuthHeader = (auth?: OpencodeAuth) => { return encoded ? `Basic ${encoded}` : null; }; +/** + * URLs whose response body we must stream chunk-by-chunk (SSE, long-running + * message streams, event subscriptions). The Tauri HTTP plugin's + * `fetch_read_body` IPC call blocks until the entire body is delivered, so + * pointing it at an infinite stream freezes the webview's main thread for + * minutes. For these endpoints we always use the webview's native fetch — + * CORS is already wide open on the openwork/opencode stack, so there's no + * reason to route them through the plugin. + */ +const STREAM_URL_RE = /\/(event|stream)(\b|\/|$|\?)/; + +function requestIsStreaming(input: RequestInfo | URL, init?: RequestInit): boolean { + const url = getRequestUrl(input); + if (STREAM_URL_RE.test(url)) return true; + const accept = + input instanceof Request + ? input.headers.get("accept") ?? input.headers.get("Accept") + : new Headers(init?.headers).get("accept") ?? new Headers(init?.headers).get("Accept"); + return typeof accept === "string" && accept.toLowerCase().includes("text/event-stream"); +} + +function nativeFetchRef(): typeof globalThis.fetch { + if (typeof window !== "undefined" && typeof window.fetch === "function") return window.fetch.bind(window); + return globalThis.fetch as typeof globalThis.fetch; +} + const createTauriFetch = (auth?: OpencodeAuth) => { const authHeader = resolveAuthHeader(auth); const addAuth = (headers: Headers) => { @@ -274,28 +300,33 @@ const createTauriFetch = (auth?: OpencodeAuth) => { }; return (input: RequestInfo | URL, init?: RequestInit) => { + // Streams must go through the webview's native fetch to avoid the + // Tauri HTTP plugin's `fetch_read_body` hang on never-closing bodies. + const shouldStream = requestIsStreaming(input, init); + const underlyingFetch = shouldStream + ? nativeFetchRef() + : (tauriFetch as unknown as typeof globalThis.fetch); + // Streams should never be timed out at the transport layer; the caller + // aborts via AbortSignal when the subscription unmounts. + const timeoutMs = shouldStream ? 0 : DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS; + if (input instanceof Request) { const headers = new Headers(input.headers); addAuth(headers); const request = new Request(input, { headers }); - return fetchWithTimeout( - tauriFetch as unknown as typeof globalThis.fetch, - request, - undefined, - DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS, - ); + return fetchWithTimeout(underlyingFetch, request, undefined, timeoutMs); } const headers = new Headers(init?.headers); addAuth(headers); return fetchWithTimeout( - tauriFetch as unknown as typeof globalThis.fetch, + underlyingFetch, input, { ...init, headers, }, - DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS, + timeoutMs, ); }; }; diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index b4fd4b51..e5c6ecb6 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -751,8 +751,22 @@ function buildAuthHeaders(token?: string, hostToken?: string, extra?: Record (isTauriRuntime() ? tauriFetch : globalThis.fetch); +// Use Tauri's fetch when running in the desktop app to avoid CORS issues. +// Stream URLs (SSE) bypass the plugin because its `fetch_read_body` IPC call +// blocks until the body closes — that freezes the webview for infinite bodies. +const OPENWORK_STREAM_URL_RE = /\/events(\b|\?)|\/event-stream\b|\/stream\b/; + +function isStreamUrl(url: string): boolean { + return OPENWORK_STREAM_URL_RE.test(url); +} + +const resolveFetch = (url?: string) => { + if (!isTauriRuntime()) return globalThis.fetch; + if (url && isStreamUrl(url)) { + return typeof window !== "undefined" ? window.fetch.bind(window) : globalThis.fetch; + } + return tauriFetch; +}; const DEFAULT_OPENWORK_SERVER_TIMEOUT_MS = 10_000; @@ -803,7 +817,7 @@ async function requestJson( options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {}, ): Promise { const url = `${baseUrl}${path}`; - const fetchImpl = resolveFetch(); + const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( fetchImpl, url, @@ -833,7 +847,7 @@ async function requestJsonRaw( options: { method?: string; token?: string; hostToken?: string; body?: unknown; timeoutMs?: number } = {}, ): Promise> { const url = `${baseUrl}${path}`; - const fetchImpl = resolveFetch(); + const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( fetchImpl, url, @@ -862,7 +876,7 @@ async function requestMultipartRaw( options: { method?: string; token?: string; hostToken?: string; body?: FormData; timeoutMs?: number } = {}, ): Promise<{ ok: boolean; status: number; text: string }>{ const url = `${baseUrl}${path}`; - const fetchImpl = resolveFetch(); + const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( fetchImpl, url, @@ -883,7 +897,7 @@ async function requestBinary( options: { method?: string; token?: string; hostToken?: string; timeoutMs?: number } = {}, ): Promise<{ data: ArrayBuffer; contentType: string | null; filename: string | null }>{ const url = `${baseUrl}${path}`; - const fetchImpl = resolveFetch(); + const fetchImpl = resolveFetch(url); const response = await fetchWithTimeout( fetchImpl, url, diff --git a/apps/app/src/app/lib/release-channels.ts b/apps/app/src/app/lib/release-channels.ts new file mode 100644 index 00000000..e423baac --- /dev/null +++ b/apps/app/src/app/lib/release-channels.ts @@ -0,0 +1,65 @@ +/** + * Release-channel concept for OpenWork desktop builds. + * + * There are two channels users can opt into: + * + * - "stable": the default. The desktop app auto-updates from the rolling + * "latest" GitHub release attached to whichever semver tag most recently + * finished the Release App workflow. macOS, Linux, Windows. + * + * - "alpha": a macOS-only rolling channel that auto-updates on every merge + * to `dev`. Alpha builds are published to a fixed GitHub release tag + * (`alpha-macos-latest`) so the updater endpoint stays stable while the + * underlying artifact is replaced on every dev push. + * + * Only the macOS (arm64) build is published to the alpha channel today. + * Linux and Windows always resolve to the stable channel. + */ + +import type { ReleaseChannel } from "../types"; + +/** Stable channel's Tauri updater manifest URL. */ +export const STABLE_UPDATER_ENDPOINT = + "https://github.com/different-ai/openwork/releases/latest/download/latest.json"; + +/** Alpha channel's Tauri updater manifest URL (macOS-only, rolling). */ +export const ALPHA_UPDATER_ENDPOINT = + "https://github.com/different-ai/openwork/releases/download/alpha-macos-latest/latest.json"; + +/** Rolling GitHub release tag that alpha macOS artifacts are published to. */ +export const ALPHA_MACOS_RELEASE_TAG = "alpha-macos-latest"; + +export type PlatformKind = "darwin" | "linux" | "windows" | "web" | "unknown"; + +/** + * Returns true when the given platform supports the alpha channel. + * + * Today alpha builds are produced only for macOS (arm64). The type-level + * conservatism here is deliberate: it's easier to widen later than to + * silently start advertising an alpha endpoint that serves no artifact. + */ +export function isAlphaChannelSupported(platform: PlatformKind): boolean { + return platform === "darwin"; +} + +/** + * Resolve the Tauri updater manifest URL for the requested channel. + * + * Falls back to the stable endpoint whenever alpha isn't supported on the + * current platform, so the caller never needs to special-case "alpha chosen + * on Linux" / "alpha chosen on Windows" etc. + */ +export function resolveUpdaterEndpoint( + channel: ReleaseChannel, + platform: PlatformKind = "darwin", +): string { + if (channel === "alpha" && isAlphaChannelSupported(platform)) { + return ALPHA_UPDATER_ENDPOINT; + } + return STABLE_UPDATER_ENDPOINT; +} + +/** Narrow an arbitrary string to a valid ReleaseChannel, defaulting to stable. */ +export function coerceReleaseChannel(value: unknown): ReleaseChannel { + return value === "alpha" ? "alpha" : "stable"; +} diff --git a/apps/app/src/app/lib/version-gate.ts b/apps/app/src/app/lib/version-gate.ts new file mode 100644 index 00000000..bf6cc53c --- /dev/null +++ b/apps/app/src/app/lib/version-gate.ts @@ -0,0 +1,147 @@ +// Version comparator + update gating helpers. +// +// Ported from dev's Solid system-state.ts (#1476 + #1512). Pure functions +// so they're reusable from any React feature site once the updater flow +// gets wired. + +import { createDenClient, readDenSettings, type DenDesktopConfig } from "./den"; + +type ParsedVersion = { + release: number[]; + prerelease: string[]; +}; + +function parseComparableVersion(value: string): ParsedVersion | null { + const normalized = value.trim().replace(/^v/i, ""); + if (!normalized) return null; + + const [versionCore] = normalized.split("+", 1); + if (!versionCore) return null; + + const [releasePart, prereleasePart = ""] = versionCore.split("-", 2); + const release = releasePart.split(".").map((segment) => Number(segment)); + if (!release.length || release.some((segment) => !Number.isInteger(segment) || segment < 0)) { + return null; + } + + const prerelease = prereleasePart + .split(".") + .map((segment) => segment.trim()) + .filter(Boolean); + + return { release, prerelease }; +} + +function comparePrereleaseIdentifiers(left: string[], right: string[]): number { + // semver-ish: absence of prerelease ranks higher than presence. + if (!left.length && !right.length) return 0; + if (!left.length) return 1; + if (!right.length) return -1; + + const count = Math.max(left.length, right.length); + for (let index = 0; index < count; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + + const leftNumeric = /^\d+$/.test(leftPart) ? Number(leftPart) : null; + const rightNumeric = /^\d+$/.test(rightPart) ? Number(rightPart) : null; + + if (leftNumeric !== null && rightNumeric !== null) { + if (leftNumeric !== rightNumeric) return leftNumeric < rightNumeric ? -1 : 1; + continue; + } + + if (leftNumeric !== null) return -1; + if (rightNumeric !== null) return 1; + + const comparison = leftPart.localeCompare(rightPart); + if (comparison !== 0) return comparison < 0 ? -1 : 1; + } + + return 0; +} + +/** + * Compare two version strings. Returns -1 / 0 / 1 as usual, or null if + * either side fails to parse. Accepts an optional leading `v` and handles + * prerelease tags (e.g. `0.11.212-alpha.3`). + */ +export function compareVersions(left: string, right: string): number | null { + const parsedLeft = parseComparableVersion(left); + const parsedRight = parseComparableVersion(right); + if (!parsedLeft || !parsedRight) return null; + + const count = Math.max(parsedLeft.release.length, parsedRight.release.length); + for (let index = 0; index < count; index += 1) { + const leftPart = parsedLeft.release[index] ?? 0; + const rightPart = parsedRight.release[index] ?? 0; + if (leftPart !== rightPart) return leftPart < rightPart ? -1 : 1; + } + + return comparePrereleaseIdentifiers(parsedLeft.prerelease, parsedRight.prerelease); +} + +/** + * Apply the org-level `allowedDesktopVersions` filter (dev #1512). When + * the array is unset, everything is allowed; when it's set, the candidate + * update version must match one of the allowed versions exactly (by + * semver comparison, so leading `v` prefixes and trailing build metadata + * are treated equivalently). + */ +export function isUpdateAllowedByDesktopConfig( + updateVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): boolean { + if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) { + return true; + } + + return desktopConfig.allowedDesktopVersions.some( + (allowedVersion) => compareVersions(updateVersion, allowedVersion) === 0, + ); +} + +/** + * Ask Den for the currently-supported latest app version (dev #1476) and + * return true only when the candidate update version is the latest + * version or older. If Den is unreachable or returns an invalid payload, + * this returns `false` — the caller must treat that as "do not surface + * the update". + * + * No-op safe: callers can invoke this without any Den auth; the client + * will omit the token when none is persisted. + */ +export async function isUpdateSupportedByDen(updateVersion: string): Promise { + try { + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + const client = createDenClient({ + baseUrl: settings.baseUrl, + apiBaseUrl: settings.apiBaseUrl, + ...(token ? { token } : {}), + }); + const metadata = await client.getAppVersionMetadata(); + const comparison = compareVersions(updateVersion, metadata.latestAppVersion); + return comparison !== null && comparison <= 0; + } catch { + return false; + } +} + +/** + * Combined gate: the update must be supported by Den (version metadata + * endpoint) AND allowed by the active org's `allowedDesktopVersions` if + * one is configured. Intended to be the single call site the React + * updater flow makes before surfacing an update as installable. + */ +export async function isUpdateAllowed( + updateVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): Promise { + if (!isUpdateAllowedByDesktopConfig(updateVersion, desktopConfig)) { + return false; + } + return isUpdateSupportedByDen(updateVersion); +} diff --git a/apps/app/src/app/lib/workspace-shell-layout.ts b/apps/app/src/app/lib/workspace-shell-layout.ts deleted file mode 100644 index f87589a1..00000000 --- a/apps/app/src/app/lib/workspace-shell-layout.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -const LEFT_SIDEBAR_WIDTH_KEY = "openwork.workspace-shell.left-width.v1"; -const RIGHT_SIDEBAR_EXPANDED_KEY = "openwork.workspace-shell.right-expanded.v3"; - -export const DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH = 260; -export const MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH = 220; -export const MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH = 420; -export const DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH = 72; - -type WorkspaceShellLayoutOptions = { - defaultLeftWidth?: number; - minLeftWidth?: number; - maxLeftWidth?: number; - collapsedRightWidth?: number; - expandedRightWidth: number; -}; - -function readStorage(key: string): string | null { - if (typeof window === "undefined") return null; - try { - return window.localStorage.getItem(key); - } catch { - return null; - } -} - -function writeStorage(key: string, value: string) { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(key, value); - } catch { - // ignore persistence failures - } -} - -function clampNumber(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -export function createWorkspaceShellLayout(options: WorkspaceShellLayoutOptions) { - const minLeftWidth = Math.max(180, options.minLeftWidth ?? MIN_WORKSPACE_LEFT_SIDEBAR_WIDTH); - const maxLeftWidth = Math.max(minLeftWidth, options.maxLeftWidth ?? MAX_WORKSPACE_LEFT_SIDEBAR_WIDTH); - const defaultLeftWidth = clampNumber( - options.defaultLeftWidth ?? DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, - minLeftWidth, - maxLeftWidth, - ); - const collapsedRightWidth = Math.max( - 56, - options.collapsedRightWidth ?? DEFAULT_WORKSPACE_RIGHT_SIDEBAR_COLLAPSED_WIDTH, - ); - const expandedRightWidth = Math.max(collapsedRightWidth, options.expandedRightWidth); - - const readLeftSidebarWidth = () => { - const raw = readStorage(LEFT_SIDEBAR_WIDTH_KEY); - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return defaultLeftWidth; - return clampNumber(parsed, minLeftWidth, maxLeftWidth); - }; - - const readRightSidebarExpanded = () => { - const raw = readStorage(RIGHT_SIDEBAR_EXPANDED_KEY); - if (raw == null) return false; - return raw === "1"; - }; - - const [leftSidebarWidth, setLeftSidebarWidth] = createSignal(readLeftSidebarWidth()); - const [rightSidebarExpanded, setRightSidebarExpanded] = createSignal(readRightSidebarExpanded()); - - createEffect(() => { - writeStorage(LEFT_SIDEBAR_WIDTH_KEY, String(clampNumber(leftSidebarWidth(), minLeftWidth, maxLeftWidth))); - }); - - createEffect(() => { - writeStorage(RIGHT_SIDEBAR_EXPANDED_KEY, rightSidebarExpanded() ? "1" : "0"); - }); - - const rightSidebarWidth = createMemo(() => - rightSidebarExpanded() ? expandedRightWidth : collapsedRightWidth, - ); - - let dragCleanup: (() => void) | null = null; - - const stopLeftSidebarResize = () => { - dragCleanup?.(); - dragCleanup = null; - if (typeof document === "undefined") return; - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - }; - - const startLeftSidebarResize = (event: PointerEvent) => { - if (event.button !== 0 || typeof window === "undefined") return; - - stopLeftSidebarResize(); - const initialX = event.clientX; - const initialWidth = leftSidebarWidth(); - - const handleMove = (moveEvent: PointerEvent) => { - const delta = moveEvent.clientX - initialX; - setLeftSidebarWidth(clampNumber(initialWidth + delta, minLeftWidth, maxLeftWidth)); - }; - - const handleStop = () => { - stopLeftSidebarResize(); - }; - - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleStop); - window.addEventListener("pointercancel", handleStop); - dragCleanup = () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleStop); - window.removeEventListener("pointercancel", handleStop); - }; - - if (typeof document !== "undefined") { - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - } - - event.preventDefault(); - }; - - const toggleRightSidebar = () => { - setRightSidebarExpanded((current) => !current); - }; - - onCleanup(() => { - stopLeftSidebarResize(); - }); - - return { - leftSidebarWidth, - rightSidebarExpanded, - rightSidebarWidth, - setRightSidebarExpanded, - startLeftSidebarResize, - toggleRightSidebar, - }; -} diff --git a/apps/app/src/app/pages/automations.tsx b/apps/app/src/app/pages/automations.tsx deleted file mode 100644 index 1c46435a..00000000 --- a/apps/app/src/app/pages/automations.tsx +++ /dev/null @@ -1,1043 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; - -import type { ScheduledJob } from "../types"; -import { useAutomations } from "../automations/provider"; -import { usePlatform } from "../context/platform"; -import { formatRelativeTime, isTauriRuntime } from "../utils"; -import { t } from "../../i18n"; - -import { - BookOpen, - Brain, - Calendar, - Clock, - MessageSquare, - Play, - PlugZap, - Plus, - RefreshCw, - Search, - Sparkles, - Trash2, - TrendingUp, - Trophy, - X, -} from "lucide-solid"; -import { useStatusToasts, type AppStatusToastTone } from "../shell/status-toasts"; - -type AutomationsFilter = "all" | "scheduled" | "templates"; -type ScheduleMode = "daily" | "interval"; - -type AutomationTemplate = { - icon: any; - name: string; - description: string; - prompt: string; - scheduleMode: ScheduleMode; - scheduleTime?: string; - scheduleDays?: string[]; - intervalHours?: number; - badge: string; -}; - -const pageTitleClass = "text-[28px] font-semibold tracking-[-0.5px] text-dls-text"; -const sectionTitleClass = "text-[15px] font-medium tracking-[-0.2px] text-dls-text"; -const panelCardClass = - "rounded-[20px] border border-dls-border bg-dls-surface p-5 transition-all hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]"; -const pillButtonClass = - "inline-flex items-center justify-center gap-1.5 rounded-full px-4 py-2 text-[13px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.18)] disabled:cursor-not-allowed disabled:opacity-60"; -const pillPrimaryClass = `${pillButtonClass} bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)]`; -const pillSecondaryClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-text hover:bg-dls-hover`; -const pillGhostClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-secondary hover:bg-dls-hover hover:text-dls-text`; -const tagClass = - "inline-flex items-center rounded-md border border-dls-border bg-dls-hover px-2 py-1 text-[11px] text-dls-secondary"; - -const DEFAULT_AUTOMATION_NAME = () => t("scheduled.default_automation_name"); -const DEFAULT_AUTOMATION_PROMPT = - "Scan recent commits and flag riskier diffs with the most important follow-ups."; -const DEFAULT_SCHEDULE_TIME = "09:00"; -const DEFAULT_SCHEDULE_DAYS = ["mo", "tu", "we", "th", "fr"]; -const DEFAULT_INTERVAL_HOURS = 6; - -const automationTemplates: AutomationTemplate[] = [ - { - icon: Calendar, - name: t("scheduled.tpl_daily_planning_name"), - description: t("scheduled.tpl_daily_planning_desc"), - prompt: - "Review my pending tasks and calendar, then draft a practical plan for today with top priorities and one follow-up reminder.", - scheduleMode: "daily", - scheduleTime: "08:30", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_weekday_morning"), - }, - { - icon: BookOpen, - name: t("scheduled.tpl_inbox_zero_name"), - description: t("scheduled.tpl_inbox_zero_desc"), - prompt: - "Summarize unread inbox messages, suggest priority order, and draft concise reply options for the top conversations.", - scheduleMode: "daily", - scheduleTime: "17:30", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_end_of_day"), - }, - { - icon: MessageSquare, - name: t("scheduled.tpl_meeting_prep_name"), - description: t("scheduled.tpl_meeting_prep_desc"), - prompt: - "Prepare meeting briefs for tomorrow with context, talking points, and questions to unblock decisions.", - scheduleMode: "daily", - scheduleTime: "18:00", - scheduleDays: ["mo", "tu", "we", "th", "fr"], - badge: t("scheduled.badge_weekday_evening"), - }, - { - icon: TrendingUp, - name: t("scheduled.tpl_weekly_wins_name"), - description: t("scheduled.tpl_weekly_wins_desc"), - prompt: - "Summarize the week into wins, blockers, and clear next steps I can share with the team.", - scheduleMode: "daily", - scheduleTime: "16:00", - scheduleDays: ["fr"], - badge: t("scheduled.badge_friday_wrapup"), - }, - { - icon: Trophy, - name: t("scheduled.tpl_learning_digest_name"), - description: t("scheduled.tpl_learning_digest_desc"), - prompt: - "Collect my saved links and notes, then draft a weekly learning digest with key ideas and follow-up actions.", - scheduleMode: "daily", - scheduleTime: "10:00", - scheduleDays: ["su"], - badge: t("scheduled.badge_weekend_review"), - }, - { - icon: Brain, - name: t("scheduled.tpl_habit_checkin_name"), - description: t("scheduled.tpl_habit_checkin_desc"), - prompt: - "Ask me for a quick progress check-in, capture blockers, and suggest one concrete next action.", - scheduleMode: "interval", - intervalHours: 6, - badge: t("scheduled.badge_every_few_hours"), - }, -]; - -const dayOptions = [ - { id: "mo", label: () => t("scheduled.day_mon"), cron: "1" }, - { id: "tu", label: () => t("scheduled.day_tue"), cron: "2" }, - { id: "we", label: () => t("scheduled.day_wed"), cron: "3" }, - { id: "th", label: () => t("scheduled.day_thu"), cron: "4" }, - { id: "fr", label: () => t("scheduled.day_fri"), cron: "5" }, - { id: "sa", label: () => t("scheduled.day_sat"), cron: "6" }, - { id: "su", label: () => t("scheduled.day_sun"), cron: "0" }, -]; - -export type AutomationsViewProps = { - busy: boolean; - selectedWorkspaceRoot: string; - createSessionAndOpen: (initialPrompt?: string) => Promise | string | void; - newTaskDisabled: boolean; - schedulerInstalled: boolean; - canEditPlugins: boolean; - addPlugin: (pluginNameOverride?: string) => void; - reloadWorkspaceEngine: () => Promise; - reloadBusy: boolean; - canReloadWorkspace: boolean; - showHeader?: boolean; -}; - -const pad2 = (value: number) => String(value).padStart(2, "0"); - -const parseCronNumbers = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) return [] as number[]; - const parts = trimmed.split(","); - const values = new Set(); - for (const part of parts) { - const segment = part.trim(); - if (!segment) continue; - if (segment.includes("-")) { - const [startRaw, endRaw] = segment.split("-"); - const start = Number.parseInt(startRaw ?? "", 10); - const end = Number.parseInt(endRaw ?? "", 10); - if (!Number.isFinite(start) || !Number.isFinite(end)) continue; - const lo = Math.min(start, end); - const hi = Math.max(start, end); - for (let i = lo; i <= hi; i += 1) values.add(i); - continue; - } - const num = Number.parseInt(segment, 10); - if (!Number.isFinite(num)) continue; - values.add(num); - } - return Array.from(values).sort((a, b) => a - b); -}; - -const humanizeCron = (cron: string) => { - const parts = cron.trim().split(/\s+/); - if (parts.length < 5) return t("scheduled.custom_schedule"); - const [minuteRaw, hourRaw, dom, mon, dowRaw] = parts; - if (!minuteRaw || !hourRaw || !dom || !mon || !dowRaw) return t("scheduled.custom_schedule"); - - if ( - minuteRaw === "0" && - hourRaw.startsWith("*/") && - dom === "*" && - mon === "*" && - dowRaw === "*" - ) { - const interval = Number.parseInt(hourRaw.slice(2), 10); - if (Number.isFinite(interval) && interval > 0) { - return interval === 1 ? t("scheduled.every_hour") : t("scheduled.every_n_hours", undefined, { interval }); - } - } - - const hour = Number.parseInt(hourRaw, 10); - const minute = Number.parseInt(minuteRaw, 10); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) return t("scheduled.custom_schedule"); - if (dom !== "*" || mon !== "*") return t("scheduled.custom_schedule"); - - const timeLabel = `${pad2(hour)}:${pad2(minute)}`; - - if (dowRaw === "*") { - return t("scheduled.every_day_at", undefined, { time: timeLabel }); - } - - const days = parseCronNumbers(dowRaw); - const normalized = new Set(days.map((d) => (d === 7 ? 0 : d))); - const allDays = [0, 1, 2, 3, 4, 5, 6]; - const weekdayDays = [1, 2, 3, 4, 5]; - const weekendDays = [0, 6]; - - if (allDays.every((d) => normalized.has(d))) return t("scheduled.every_day_at", undefined, { time: timeLabel }); - if ( - weekdayDays.every((d) => normalized.has(d)) && - !weekendDays.some((d) => normalized.has(d)) - ) { - return t("scheduled.weekdays_at", undefined, { time: timeLabel }); - } - if ( - weekendDays.every((d) => normalized.has(d)) && - !weekdayDays.some((d) => normalized.has(d)) - ) { - return t("scheduled.weekends_at", undefined, { time: timeLabel }); - } - - const labels: Record = { - 0: t("scheduled.day_sun"), - 1: t("scheduled.day_mon"), - 2: t("scheduled.day_tue"), - 3: t("scheduled.day_wed"), - 4: t("scheduled.day_thu"), - 5: t("scheduled.day_fri"), - 6: t("scheduled.day_sat"), - }; - - const list = Array.from(normalized) - .filter((d) => d >= 0 && d <= 6) - .sort((a, b) => a - b) - .map((d) => labels[d] ?? String(d)) - .join(", "); - - return list ? t("scheduled.days_at", undefined, { days: list, time: timeLabel }) : t("scheduled.at_time", undefined, { time: timeLabel }); -}; - -const buildCronFromDaily = (timeValue: string, days: string[]) => { - const [hour, minute] = timeValue.split(":"); - if (!hour || !minute) return ""; - const hourValue = Number.parseInt(hour, 10); - const minuteValue = Number.parseInt(minute, 10); - if (!Number.isFinite(hourValue) || !Number.isFinite(minuteValue)) return ""; - if (!days.length) return ""; - if (days.length === dayOptions.length) { - return `${minuteValue} ${hourValue} * * *`; - } - const daySpec = dayOptions - .filter((day) => days.includes(day.id)) - .map((day) => day.cron) - .join(","); - return daySpec ? `${minuteValue} ${hourValue} * * ${daySpec}` : ""; -}; - -const buildCronFromInterval = (hours: number) => { - if (!Number.isFinite(hours) || hours <= 0) return ""; - const interval = Math.max(1, Math.round(hours)); - return `0 */${interval} * * *`; -}; - -const taskSummary = (job: ScheduledJob) => { - const run = job.run; - if (run?.command) { - const args = run.arguments ? ` ${run.arguments}` : ""; - return `${run.command}${args}`; - } - const prompt = run?.prompt ?? job.prompt; - return prompt?.trim() || t("scheduled.task_summary_no_prompt"); -}; - -const toRelative = (value?: string | null) => { - if (!value) return t("scheduled.never"); - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) return t("scheduled.never"); - return formatRelativeTime(parsed); -}; - -const templateScheduleLabel = (template: AutomationTemplate) => { - if (template.scheduleMode === "interval") { - const interval = template.intervalHours ?? DEFAULT_INTERVAL_HOURS; - return interval === 1 ? t("scheduled.every_hour") : t("scheduled.every_n_hours", undefined, { interval }); - } - return humanizeCron( - buildCronFromDaily( - template.scheduleTime ?? DEFAULT_SCHEDULE_TIME, - template.scheduleDays ?? DEFAULT_SCHEDULE_DAYS, - ), - ); -}; - -const statusLabel = (status?: string | null) => { - if (!status) return t("scheduled.not_run_yet"); - if (status === "running") return t("scheduled.running_status"); - if (status === "success") return t("scheduled.success_status"); - if (status === "failed") return t("scheduled.failed_status"); - return status; -}; - -const statusTagClass = (status?: string | null) => { - if (status === "success") { - return "inline-flex items-center rounded-md border border-emerald-7/30 bg-emerald-3/40 px-2 py-1 text-[11px] text-emerald-11"; - } - if (status === "failed") { - return "inline-flex items-center rounded-md border border-red-7/30 bg-red-3/40 px-2 py-1 text-[11px] text-red-11"; - } - if (status === "running") { - return "inline-flex items-center rounded-md border border-amber-7/30 bg-amber-3/40 px-2 py-1 text-[11px] text-amber-11"; - } - return tagClass; -}; - -const TemplateCard = (props: { - template: AutomationTemplate; - disabled: boolean; - onUse: () => void; -}) => { - const Icon = props.template.icon; - return ( -
-
-
- -
-
-

{props.template.name}

-

- {props.template.description} -

-
- {props.template.badge} - {templateScheduleLabel(props.template)} -
-
-
- -
- {t("scheduled.template_badge")} - -
-
- ); -}; - -const JobCard = (props: { - job: ScheduledJob; - busy: boolean; - sourceLabel: string; - onRun: () => void; - onDelete: () => void; -}) => { - const summary = createMemo(() => taskSummary(props.job)); - const scheduleLabel = createMemo(() => humanizeCron(props.job.schedule)); - const status = createMemo(() => props.job.lastRunStatus ?? null); - - return ( -
-
-
- -
-
-
-

{props.job.name}

- {statusLabel(status())} -
-

- {summary()} -

-
- {scheduleLabel()} - {props.sourceLabel} - - {props.job.source} - -
-
-
{t("scheduled.last_run_prefix")} {toRelative(props.job.lastRunAt)}
-
{t("scheduled.created_prefix")} {toRelative(props.job.createdAt)}
-
-
-
- -
- {t("scheduled.filter_scheduled")} -
- - -
-
-
- ); -}; - -export default function AutomationsView(props: AutomationsViewProps) { - const automations = useAutomations(); - const platform = usePlatform(); - const statusToasts = useStatusToasts(); - - const [searchQuery, setSearchQuery] = createSignal(""); - const [activeFilter, setActiveFilter] = createSignal("all"); - const [installingScheduler, setInstallingScheduler] = createSignal(false); - const [schedulerInstallRequested, setSchedulerInstallRequested] = createSignal(false); - const [deleteTarget, setDeleteTarget] = createSignal(null); - const [deleteBusy, setDeleteBusy] = createSignal(false); - const [deleteError, setDeleteError] = createSignal(null); - const [createModalOpen, setCreateModalOpen] = createSignal(false); - const [createBusy, setCreateBusy] = createSignal(false); - const [createError, setCreateError] = createSignal(null); - const [automationName, setAutomationName] = createSignal(DEFAULT_AUTOMATION_NAME()); - const [automationPrompt, setAutomationPrompt] = createSignal(DEFAULT_AUTOMATION_PROMPT); - const [scheduleMode, setScheduleMode] = createSignal("daily"); - const [scheduleTime, setScheduleTime] = createSignal(DEFAULT_SCHEDULE_TIME); - const [scheduleDays, setScheduleDays] = createSignal([...DEFAULT_SCHEDULE_DAYS]); - const [intervalHours, setIntervalHours] = createSignal(DEFAULT_INTERVAL_HOURS); - const [lastUpdatedNow, setLastUpdatedNow] = createSignal(Date.now()); - - createEffect(() => { - if (typeof window === "undefined") return; - const interval = window.setInterval(() => setLastUpdatedNow(Date.now()), 1_000); - onCleanup(() => window.clearInterval(interval)); - }); - - createEffect(() => { - if (props.schedulerInstalled) { - setSchedulerInstallRequested(false); - } - }); - - const showToast = (title: string, tone: AppStatusToastTone = "info") => { - statusToasts.showToast({ title, tone }); - }; - - const resetDraft = (template?: AutomationTemplate) => { - setAutomationName(template?.name ?? DEFAULT_AUTOMATION_NAME()); - setAutomationPrompt(template?.prompt ?? DEFAULT_AUTOMATION_PROMPT); - setScheduleMode(template?.scheduleMode ?? "daily"); - setScheduleTime(template?.scheduleTime ?? DEFAULT_SCHEDULE_TIME); - setScheduleDays([...(template?.scheduleDays ?? DEFAULT_SCHEDULE_DAYS)]); - setIntervalHours(template?.intervalHours ?? DEFAULT_INTERVAL_HOURS); - setCreateError(null); - }; - - const supported = createMemo(() => { - if (automations.jobsSource() === "remote") return true; - return isTauriRuntime() && props.schedulerInstalled && !schedulerInstallRequested(); - }); - - const schedulerGateActive = createMemo(() => { - if (automations.jobsSource() !== "local") return false; - if (!isTauriRuntime()) return false; - return !props.schedulerInstalled || schedulerInstallRequested(); - }); - - const automationDisabled = createMemo( - () => props.newTaskDisabled || schedulerGateActive() || createBusy(), - ); - - const sourceLabel = createMemo(() => - automations.jobsSource() === "remote" ? t("scheduled.source_remote") : t("scheduled.source_local"), - ); - - const sourceDescription = createMemo(() => - automations.jobsSource() === "remote" - ? t("scheduled.subtitle_remote") - : t("scheduled.subtitle_local"), - ); - - const supportNote = createMemo(() => { - if (automations.jobsSource() === "remote") return null; - if (!isTauriRuntime()) return t("scheduled.desktop_required"); - if (!props.schedulerInstalled || schedulerInstallRequested()) return null; - return null; - }); - - const lastUpdatedLabel = createMemo(() => { - lastUpdatedNow(); - if (!automations.jobsUpdatedAt()) return t("scheduled.not_synced_yet"); - return formatRelativeTime(automations.jobsUpdatedAt() as number); - }); - - const filteredJobs = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - const items = automations.jobs(); - if (!query) return items; - return items.filter((job) => { - const summary = taskSummary(job).toLowerCase(); - const schedule = humanizeCron(job.schedule).toLowerCase(); - return ( - job.name.toLowerCase().includes(query) || - summary.includes(query) || - schedule.includes(query) - ); - }); - }); - - const filteredTemplates = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - if (!query) return automationTemplates; - return automationTemplates.filter((template) => { - return ( - template.name.toLowerCase().includes(query) || - template.description.toLowerCase().includes(query) || - template.badge.toLowerCase().includes(query) - ); - }); - }); - - const showJobsSection = createMemo(() => activeFilter() !== "templates"); - const showTemplatesSection = createMemo(() => activeFilter() !== "scheduled"); - - const cronExpression = createMemo(() => { - if (scheduleMode() === "interval") { - return buildCronFromInterval(intervalHours()); - } - return buildCronFromDaily(scheduleTime(), scheduleDays()); - }); - - const cronPreviewLabel = createMemo(() => { - const cron = cronExpression(); - return cron ? humanizeCron(cron) : null; - }); - - const openSchedulerDocs = () => { - platform.openLink("https://github.com/different-ai/opencode-scheduler"); - }; - - const refreshJobs = () => { - if (props.busy) return; - void automations.refresh({ force: true }); - }; - - const handleInstallScheduler = async () => { - if (installingScheduler() || !props.canEditPlugins) return; - setInstallingScheduler(true); - setSchedulerInstallRequested(true); - try { - await Promise.resolve(props.addPlugin("opencode-scheduler")); - showToast(t("scheduled.scheduler_install_requested"), "success"); - } catch (error) { - setSchedulerInstallRequested(false); - showToast( - error instanceof Error ? error.message : t("scheduled.prepare_error_fallback"), - "error", - ); - } finally { - setInstallingScheduler(false); - } - }; - - const openCreateModal = () => { - if (automationDisabled()) return; - resetDraft(); - setCreateModalOpen(true); - }; - - const openCreateModalFromTemplate = (template: AutomationTemplate) => { - if (automationDisabled()) return; - resetDraft(template); - setCreateModalOpen(true); - }; - - const closeCreateModal = () => { - setCreateModalOpen(false); - setCreateError(null); - setCreateBusy(false); - }; - - const handleCreateAutomation = async () => { - if (automationDisabled()) return; - const plan = automations.prepareCreateAutomation({ - name: automationName(), - prompt: automationPrompt(), - schedule: cronExpression(), - workdir: props.selectedWorkspaceRoot, - }); - if (!plan.ok) { - setCreateError(plan.error); - return; - } - - setCreateBusy(true); - setCreateError(null); - try { - await Promise.resolve(props.createSessionAndOpen(plan.prompt)); - setCreateModalOpen(false); - showToast(t("scheduled.prepared_automation_in_chat"), "success"); - } catch (error) { - setCreateError( - error instanceof Error ? error.message : t("scheduled.prepare_error_fallback"), - ); - } finally { - setCreateBusy(false); - } - }; - - const handleRunAutomation = async (job: ScheduledJob) => { - if (!supported() || props.busy) return; - const plan = automations.prepareRunAutomation(job, props.selectedWorkspaceRoot); - if (!plan.ok) { - showToast(plan.error, "warning"); - return; - } - await Promise.resolve(props.createSessionAndOpen(plan.prompt)); - showToast(t("scheduled.prepared_job_in_chat", undefined, { name: job.name }), "success"); - }; - - const confirmDelete = async () => { - const target = deleteTarget(); - if (!target) return; - setDeleteBusy(true); - setDeleteError(null); - try { - await automations.remove(target.slug); - setDeleteTarget(null); - showToast(t("scheduled.removed_job", undefined, { name: target.name }), "success"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setDeleteError(message || t("scheduled.delete_error_fallback")); - } finally { - setDeleteBusy(false); - } - }; - - const toggleDay = (id: string) => { - setScheduleDays((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return Array.from(next); - }); - }; - - const updateIntervalHours = (value: string) => { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) return; - const bounded = Math.min(24, Math.max(1, parsed)); - setIntervalHours(bounded); - }; - - const jobsEmptyMessage = createMemo(() => { - const query = searchQuery().trim(); - if (query) return t("scheduled.no_automations_match", undefined, { query }); - if (schedulerGateActive()) return t("scheduled.install_scheduler_hint"); - return t("scheduled.empty_hint"); - }); - - return ( -
-
-
-
- -

{t("scheduled.title")}

-
-

- {t("scheduled.page_description")} -

-
- -
- - - -
-
- -
-
- - setSearchQuery(event.currentTarget.value)} - placeholder={t("scheduled.search_placeholder")} - class="w-full rounded-xl border border-dls-border bg-dls-surface py-3 pl-11 pr-4 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" - /> -
- -
- - {(filter) => ( - - )} - -
-
-
- - -
-
-
-
- -
-
-
- {props.schedulerInstalled - ? t("scheduled.reload_activate_title") - : t("scheduled.install_scheduler_title")} -
-

- {props.schedulerInstalled - ? t("scheduled.reload_activate_hint") - : t("scheduled.install_scheduler_hint")} -

-
-
-
- - -
-
-
-
- - -
- {supportNote()} -
-
- - -
- {automations.jobsStatus()} -
-
- - -
- {deleteError()} -
-
- - -
-
-
-

{t("scheduled.your_automations")}

-

{sourceDescription()}

-
-
- {sourceLabel()} · {t("scheduled.last_updated_prefix")} {lastUpdatedLabel()} -
-
- - - {jobsEmptyMessage()} -
- } - > -
-
- - {(job) => ( - void handleRunAutomation(job)} - onDelete={() => setDeleteTarget(job)} - /> - )} - -
-
-
- - - - -
-
-
-

{t("scheduled.quick_start_templates")}

-

- {t("scheduled.quick_start_templates_desc")} -

-
-
{t("scheduled.template_count", undefined, { count: filteredTemplates().length })}
-
- - - {t("scheduled.no_templates_match")} -
- } - > -
-
- - {(template) => ( - openCreateModalFromTemplate(template)} - /> - )} - -
-
-
- - - - -
-
-
-
-

{t("scheduled.delete_confirm_title")}

-

- {t("scheduled.delete_confirm_desc", undefined, { source: sourceLabel().toLowerCase() })} -

-
- -
- {deleteTarget()?.name} -
- -
- - -
-
-
-
-
- - -
-
-
-
-
{t("scheduled.create_title")}
-

- {t("scheduled.create_desc")} -

-
- -
- -
-
- - setAutomationName(event.currentTarget.value)} - class="w-full rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" - /> -
- -
- -